Configuration Reference¶
All runtime configuration in Algol is managed through the Configuration Store, owned by the configurer. Each config is a JSON object with a "type" discriminator and belongs to a specific component reference (moduleId.componentName.componentId).
The CLI accepts TOML (default) or JSON. TOML is a human-friendly view of the same JSON wire format.
The field tables below are generated from the JSON Schema documents, one per config type per schema generation (<type>.v<N>.schema.json, RFC-012): the schemagen:begin / schemagen:end comments delimit what tools/schemagen writes on every run, and everything outside them is written by hand. An empty description in a generated table is a field whose struct carries no doc comment yet.
Most entries also carry two status sections, which are two axes and never derived from one another: Configuration status is what the owning module answers to an update of that config, and Application status is the health telemetry it publishes while running. In the configuration-status tables the Message (example) column is illustrative wording, not a contract - the text is built where the status is raised and changes with it. Use it to recognise a status you are looking at; match on the status code, never on the sentence.
Module availability
Algol devices run a subset of modules depending on hardware and deployment. A config type is only meaningful when the module that owns it is active - for example, chrp.loradevice.* requires the ChirpStack module. The reference below lists all config types supported by the platform; your device may expose only a subset.
How configuration works¶
- Update - send a config payload to the configurer. The running config is updated in memory immediately.
- Commit - persist all valid running configs to disk so they survive a reboot.
- On startup the configurer reads persisted configs from disk and re-publishes them.
See Configurer for persistence details.
Applying configuration¶
TOML is the default input format for algol config update. Pipe a file via stdin, or pass JSON with --format json. To change enabled or a named property on an existing config without replacing the body, use algol config patch (see CLI).
All configuration types¶
Writable types¶
| Type | Component convention | Description |
|---|---|---|
bridge |
sys.bridge.<iface> |
Bridge network interface |
channel |
<module>.channel.<id> |
Data channel with processor pipeline |
dns |
sys.dns.main |
DNS nameservers |
dummyintf |
sys.dummyintf.<iface> |
Dummy network interface |
ethernet |
sys.ethernet.<iface> |
Ethernet interface |
file |
<module>.file.<id> |
File content referenced by URL |
flow |
flower.flow.<id> |
Default channels for measurements |
gate |
gate.gate.main |
Cloud telemetry forwarding |
gpsapp |
gps.gpsapp.main |
GNSS receiver: port speed and reporting rate |
gsm |
sys.gsm.<iface> |
GSM/mobile data interface |
httpendpoint |
<module>.httpendpoint.<id> |
HTTP webhook endpoint served by a module's HTTP server (webui so far) |
kubepod |
sandbox.kubepod.<podId> |
Container pod (the sandbox module is retired) |
led |
<module>.led.<id> |
LED constraints and initial state |
loradevice |
chrp.loradevice.<devEUI> |
LoRaWAN end-device |
netprobe |
sys.netprobe.<id> |
Active connectivity probe whose counters a trigger can act on |
loragateway |
chrp.loragateway.<gatewayId> |
LoRaWAN gateway |
ntp |
chrony.ntp.main |
NTP servers and pools |
props |
cfg.props.<id> |
Writable bag of configuration properties |
recstorage |
recorder.recstorage.main |
Recording medium, catalog bounds, and write gate |
resolution |
cfg.resolution.<id> |
Envelope that binds properties into a target config |
role |
cfg.role.<name> |
Configured role: what its holders may read, patch and write |
s3upload |
recorder.s3upload.<id> |
S3 upload settings |
secret |
cfg.secret.<id> |
Declaration of a secret: its kind and the config types that may reference it |
secomea |
secomea.secomea.main |
Secomea SiteManager |
systemdapp |
<module>.systemdapp.<id> |
Sandboxed workload from a rootfs image, run as a systemd unit |
systemdmount |
sys.systemdmount.<slot> |
Host mount slot for a removable or recording medium |
systemdscript |
<module>.systemdscript.<id> |
Script run as a systemd unit |
systemdunit |
sys.systemdunit.<id> |
A host systemd unit algol addresses but did not create |
teapot |
cfg.teapot.<id> |
Probe config: held and mirrored, with no effect on the device |
trigger |
<module>.trigger.<id> |
Match condition + action |
usbexport |
recorder.usbexport.<id> |
Export Recordings to a removable medium |
webuser |
webui.webuser.<name> |
Web UI user: role and password secret reference |
wlan |
sys.wlan.<iface> |
WiFi interface, as client or access point |
Every writable type above participates in resolution (type = "resolution", targetConfigType = "<type>"). For most types that means an explicit envelope that binds property values into the target's fields before apply; props is the same path with an empty targetConfig - its bag is the config, so a bare type = "props" body is wrapped into a resolution envelope on ingest.
Read-only types¶
These are published by modules at runtime. They cannot be written through the config store.
| Type | Component convention | Description |
|---|---|---|
bundleinfo |
cfg.bundleinfo.<bundleId> |
Installed configuration bundle, with per-member drift (derived from a bundle file) |
channelinfo |
<module>.channelinfo.<id> |
Channel runtime metadata (derived from a channel config) |
deviceinfo |
sys.deviceinfo.main |
This device's identity: deviceId, tenantId, serialNr, hostname |
dsource |
<module>.dsource.<id> |
Data source runtime info |
fileinfo |
<module>.fileinfo.<id> |
Resolved file metadata (derived from a file config) |
ntppps |
chrony.ntppps.main |
PPS device path and NMEA driver settings |
ociimage |
sandbox.ociimage.<id> |
Container image in the sandbox store (the sandbox module is retired) |
userkeyinfo |
nats.userkeyinfo.<key> |
One person's key the bus admits, and the role it holds (published by the nats app) |
modulekeyinfo |
nats.modulekeyinfo.<key> |
One module's key the bus admits, and the module name it is bound to (published by the nats app) |
propsinfo |
cfg.propsinfo.<id> |
Resolved property values (derived from a props config) |
systemdmountinfo |
sys.systemdmountinfo.<slot> |
Mount-slot runtime (derived from a systemdmount config) |
teapotinfo |
cfg.teapotinfo.<id> |
Probe mirror (derived from a teapot config) |
webuserinfo |
webui.webuserinfo.<name> |
The public half of the key webui holds for a web user (published by webui) |
Shared: configuration properties¶
Several config types carry a properties array of typed values. Properties can be literal strings, numbers, JSON values, or references to other configs.
Other configs read resolved values via cfg.propsinfo.<id> with a JSONPath selector (e.g. $.phone_number).
To inject property values directly into a config's own fields (e.g. a systemdscript filename or a systemdapp rootfs), author it as a resolution config - set type = "resolution" and targetConfigType to the real type. The configurer resolves the properties bag and binds the target fields before dispatching the bound config to the module (RFC-011). Three binding forms are available:
| Form | Where | Result |
|---|---|---|
$name / ${name} (inline) |
string fields | flat substitution of the property's string value; use the ${name} braces when the reference sits flush against surrounding text (e.g. pre${name}post) |
{ "$value": "name" } |
any typed field | injects the property's typed JSON value |
{ "$template": "..." } |
string fields only | renders a full template against the property bag (reserve for logic beyond a plain substitution) |
A worked example: a channel authored as a resolution config over a size and a label property. The file keeps the envelope, since it carries a bag.
type = "resolution"
targetConfigType = "channel"
[[properties]]
type = "int"
name = "size"
value = 64
[[properties]]
type = "string"
name = "label"
value = "vibration"
[targetConfig]
enabled = true
description = "IMU $label" # a string field: the text "IMU vibration"
[[targetConfig.processors]]
type = "windower"
windowSize = { "$value" = "size" } # an integer field: the value 64
{
"type": "resolution",
"targetConfigType": "channel",
"properties": [
{ "type": "int", "name": "size", "value": 64 },
{ "type": "string", "name": "label", "value": "vibration" }
],
"targetConfig": {
"enabled": true,
"description": "IMU $label",
"processors": [
{ "type": "windower", "windowSize": { "$value": "size" } }
]
}
}
A $name token always binds text: windowSize = "$size" is not the integer 64 but the string "64", which the field cannot hold. The configurer refuses the update in its own answer, naming the binding, its path in the target body and the form that binds the value:
property binding: processors[0].windowSize: the $size token is a string, the field takes an integer - for the value itself write {"$value": "size"}
The same check refuses a property whose value the field cannot hold - a string property behind { "$value": "size" } on windowSize, say - before the bound body is decoded. What it does not judge is left to the module: a literal written by hand, a property that has not resolved yet, and a field the schema leaves open.
A property is one of the variants below, discriminated by its type. The tables are those of the props bag; the resolution envelope's properties carry the same variants.
string¶
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be string. |
name |
string | yes | Name is the property's name, what a binding ($name, {"$value": "name"}) and a jsonPath on the propsinfo mirror address it by. |
description |
string | no | Description is free text about the property. |
value |
string | no | Value is the string. |
int¶
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be int. |
name |
string | yes | Name is the property's name, what a binding and a jsonPath address it by. |
description |
string | no | Description is free text about the property. |
value |
int | no | Value is the integer. |
float¶
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be float. |
name |
string | yes | Name is the property's name, what a binding and a jsonPath address it by. |
description |
string | no | Description is free text about the property. |
value |
number | no | Value is the number. |
json¶
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be json. |
name |
string | yes | Name is the property's name, what a binding and a jsonPath address it by. |
description |
string | no | Description is free text about the property. |
value |
any | no | Value is any JSON value: an object, an array or a scalar. |
configRef¶
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be configRef. |
name |
string | yes | Name is the property's name, what a binding and a jsonPath address it by. |
description |
string | no | Description is free text about the property. |
ref |
component ref | yes | Ref is the component whose config the value is read from, a three-segment component reference (sys.deviceinfo.main). |
jsonPath |
string | no | JsonPath selects the value inside that config ("$.deviceId"); empty selects the whole config. A path matching several nodes resolves to an array of them. |
timeout |
number | no | Timeout is how long to wait for the reference to resolve, in seconds: 0 does not wait, a positive value waits up to that long, a negative one waits indefinitely. |
ignore |
bool | no | Ignore leaves the property out when the reference cannot be resolved, rather than failing the config. |
onError |
string | no | OnError controls behavior when the referenced component is in ERROR: "wait" (default) keeps waiting per Timeout (the dependency may recover); "fail" errors immediately. One of wait, fail. |
jsonPath is the RFC 9535 subset documented with the resolver; a path matching several nodes resolves to an array of them.
error¶
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be error. |
name |
string | no | Name is the name of the property that could not be resolved. |
description |
string | no | Description is the unresolved property's description. |
message |
string | no | Message says why the reference could not be resolved. |
Never authored: the configurer produces an error property in place of a configRef it could not resolve, so a consumer sees that a dependency is missing.
Used by: props (native property bag), and all other writable config types via resolution configs (type = "resolution", targetConfigType = "<type>").
Example¶
To change a value later, patch by name. The type is the one already on the property.
Shared: IPv4 configuration¶
Used by bridge, ethernet, wlan, dummyintf and gsm under the ipv4 key. The fields (ipv4.method, ipv4.addresses, ipv4.routes[], ipv4.sharedDhcp, ipv4.routeMetric) are listed in each type's own table; the rules below hold for all of them, except where gsm is called out - a modem is addressed by its carrier, so it takes only method (auto or disabled) and routeMetric.
shared is the same rule on all four types. A /32 shared has no DHCP range. addresses may be present with any method, including disabled (held on the profile while IPv4 is off). method is required whenever ipv4 is present, so a config patch that creates ipv4 with only addresses fails.
Applying IPv4 writes NetworkManager ipv6.method: disabled for shared, auto for the other methods, so rolling shared back to auto restores the BSP default.
routeMetric is how several uplinks are ordered. Every connection with a gateway contributes a default route, the lowest metric wins, and NetworkManager withdraws a route when its link goes away and re-elects the next one - so failover needs no config. What the field decides is the order: unset, NetworkManager takes the metric from the interface type (Ethernet 100, bridge 425, WiFi 600, cellular 700), which puts a cellular link last; set, the number is the interface's own and is what makes cellular preferred over a connected Ethernet socket. One value covers IPv4 and IPv6, because the two families elect their default routes independently and a v4-only preference would leave every destination with an AAAA record on the other link.
Two things follow from it being a write to the NetworkManager profile:
- A metric pinned outside algol is overridden, and removing the field undoes it. An unset
routeMetricdeletes the metric from the profile rather than leaving the last number written there, so the interface returns to NetworkManager's default. Where a metric comes from a NetworkManager global default (conf.d) rather than the profile, removing the field falls back to that global - which is what a global default is for. Agsmconfig that declares noipv4object at all writes nothing, so a pin in the image stays untouched until the section is written. - A metric change re-activates the connection. Every update of an enabled connection does, so changing the number on a cellular profile re-dials the modem and changing it on the link that carries the device's uplink drops it for a few seconds.
sharedDhcp = "none" is the LAN whose segment already has a DHCP server on it: the interface keeps its address, keeps forwarding and masquerading, and keeps relaying DNS on the gateway address, but hands out no leases, so hosts behind it are addressed statically or by that other server. NetworkManager has no method that does this - a shared connection always starts its dnsmasq - so sys delivers a no-dhcp-interface drop-in into the host's /etc/NetworkManager/dnsmasq-shared.d before the connection is activated. Three consequences:
- The drop-in directory has to be bind-mounted into the
syscontainer (--dnsmasq-shared-dir), which the shipped per-target deployments do. Where it is not mounted,noneisERRORon the component rather than a LAN that serves DHCP anyway. - The reported value is read back from that directory, not from the NetworkManager profile, and every drop-in in it counts - including one an operator delivered with
fileandsystemdscript. A LAN whose DHCP somebody else switched off reportsnone. - Deleting the config leaves the drop-in in place. Delete is a system-twin withdrawal: the profile keeps its settings, so the interface stays a LAN, and removing the file would start handing out addresses on that segment as the effect of a delete.
bundleinfo¶
Component ref: cfg.bundleinfo.<bundleId>
Read-only. Published by the module; not writable through the config store.
One per bundle that has at least one variant installed, carrying a block per variant
(RFC-014).
A bundle is the key a config record is stamped with; a variant is one installed set of configs
of it, stored as exactly one file under /var/algol/cfg/bundles/. Two variants of one bundle
are two files and one component, which is what says they are alternatives: activating one
evicts what the other placed.
Neither is a config: a variant is a document with no component of its own, no enabled flag
and no stored activation state, and a bundle exists only while a variant of it is installed or
a running config carries its stamp. bundle and manifest are permanently reserved in the
config-type namespace so nothing can turn them into one.
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be bundleinfo. |
id |
string | yes | ID is the bundle: the key its members are stamped with, and the component id this is published under (cfg.bundleinfo.<id>), so it is a component ref part. |
variants |
array | no | Variants is one entry per installed variant of this bundle, ordered by variant name. The order is presentation only. Nothing compares two variant names, there is no latest and no succession the device reads (RFC-014): sorting is what makes the view stable between ticks, not a statement about which variant supersedes which. |
variants[].variant |
string | yes | Variant is which variant of the bundle this is, from the document rather than the filename. With the bundle it forms the address <bundle>@<variant>, which is how every verb and every listing names a variant. |
variants[].description |
string | no | Description is the author's free-form note from the document. |
variants[].createdAt |
timestamp | no | CreatedAt is the author's claim about when the artifact was written. It is not an ordering over variants - it says when a file was written, never which one wins. |
variants[].digest |
string | no | Digest is the content digest of this variant's file bytes, algorithm:hex per lib/messaging/digest. Derived from the file, never stored in the document - a document cannot carry a digest of itself. |
variants[].installedAt |
timestamp | no | InstalledAt is the file's modification time: when this device got the variant, as opposed to when it was authored. |
variants[].error |
string | no | Error is why this variant is unusable, empty when it is not: its file does not parse or does not validate, or a member of it needs a schema generation this build does not have. It is per variant because the component's status is not: one damaged variant among several makes the whole component ERROR, and without this a reader could see that something is wrong but not which file it is wrong in. The component's status message names the variants too, but a message is an illustration rather than a contract, and nothing should have to parse one to draw a row. |
variants[].configRefs |
array | no | ConfigRefs is one entry per member, each carrying the hash pair that says whether the member matches, drifted, or is not running at all, and the bundle currently holding the ref. |
variants[].configRefs[].ref |
component ref | no | Ref is the member's component reference. |
variants[].configRefs[].bundleHash |
string | no | BundleHash is the hash of the member's body as the bundle file carries it, taken after migrating it to the current schema generations - so a bundle written against an older generation does not read as drift when its content is in fact identical. |
variants[].configRefs[].intentHash |
string | no | IntentHash is the hash of the ref's current running intent, absent when the ref is not running. Intent, not the bound form: what the operator asked for is what a bundle carries, and comparing against a resolved body would report drift on every device whose property values differ. |
variants[].configRefs[].intentBundle |
string | no | IntentBundle is the bundle stamped on that same running intent - who currently holds the ref, which is what an activation of this document is judged against. It is absent both when the ref is not running and when it runs unattributed, and IntentHash tells those apart: no hash means nothing is running, a hash without an attribution means something runs that no bundle placed, which bundle activate --adopt claims. Read against the ID this view is published under: a different value is another bundle's claim, which an activation refuses, and it is also what a half-finished rename looks like - the document declaring a new key while its members still carry the old. Both readings hold only for a member whose running half could be read at all. When it could not, the two fields are empty without either absence being a statement about the member, and the component reports ERROR naming the ref. |
The configRefs hash pair is what says whether a member is in step. Both hashes are taken
over the same canonical form at the current schema generations, so a bundle written against an
older generation does not read as drift:
bundleHash vs intentHash |
Meaning |
|---|---|
| equal | the member runs the bundle's body |
| differing | the member drifted - something else is running under that ref |
intentHash absent |
nothing is running under that ref |
intentBundle answers the other question - not whether the content matches but whether the ref
is this bundle's to claim - and it is read against the bundle's own bundle. It is taken off
the same running intent as intentHash, so it is what an activation will judge:
intentBundle |
Meaning | What bundle activate does |
|---|---|---|
equals the bundle's bundle |
this bundle placed the member | replaces it |
absent, intentHash present |
something runs under the ref that no bundle placed | rejects; --adopt claims it |
absent, intentHash absent |
nothing is running under the ref | creates it |
| a different bundle | another bundle holds the ref, however identical the content | rejects - fix the bundles, or config delete --bundle |
A whole variant whose members read as held by another bundle is a half-finished move: the
content did not change, so the hash pairs keep matching while every member says it belongs to
somebody else. There is no retag verb, because a bundle is not declared anywhere - it is the
left half of the file's own address, so moving members to another bundle is authoring one
(bundle update new@v1 --add ... --adopt --force) and tearing the old attribution down
(config delete --bundle old).
Both tables read a member whose halves could be computed. Where one could not - a body this
build cannot decode, an intent that will not hash - that member's fields are empty without the
absence meaning what the tables say, and the component reports ERROR naming the ref (the last
two rows below). A per-member unknown is a status, never an absence, because an absence is a
claim a reader acts on.
"Active" is not published. It is derivable per member from the pair above (a UI may aggregate); a declared aggregate would be a second place for the same fact to go stale.
Configuration status¶
| Status | Message (example) | Meaning | Cleared by |
|---|---|---|---|
OK |
- | Every installed variant's file is valid and applicable. Does not mean any of them is active. | - |
WAIT |
a <id> bundle operation is in progress |
An activation or a teardown of this bundle is in flight, so the hash pairs would describe neither the state before it nor the state after. It covers every variant at once, because activating one is exactly what makes its siblings' pairs unknowable. | the operation finishing |
WAIT |
config store is still loading; running intents are not known yet |
The running set is not knowable yet, so "not running" cannot be told from "not known". | the next status tick, once the store finished its replay |
ERROR |
bundle <id>: variant <v>: cannot decode bundle document: ... |
A variant's file is unreadable, unparseable or structurally invalid (a truncated upload). It stays installed with its digest so it can be identified and replaced, and the reason is on that variant's error field as well as in the message. |
replacing the file |
ERROR |
bundle <id>: variant <v>: cannot run on this build: <ref> declares schema version <n>, this build supports <m> |
A member needs a schema generation this firmware does not have. The variant installs fine and stays inspectable; activation refuses, naming these refs. Those members carry no bundleHash (their body would decode at the wrong generation), while the members this build can read are still compared. |
a firmware upgrade, or a variant authored for this build |
ERROR |
bundle <id>: variant <v>: unusable members: ... |
A member could not be migrated to the current generations or does not conform to its type's schema - or its running intent could not be hashed (<ref>: cannot hash the running intent), in which case that member's intentHash and intentBundle are both absent without meaning "not running". |
replacing the file; for an unhashable running intent, the next tick after that config is corrected |
One damaged variant makes the whole component ERROR - a component has one status and a bundle
has several files - so which file is wrong is read from that variant's own error field rather
than from the message.
Never reached: a bundle whose last variant was deleted has no component at all, and CONFIG_UPDATE /
CONFIG_DELETE against a bundleinfo ref are refused outright - a bundle is written by
bundle update and removed by bundle delete.
Application status¶
No typed status is registered under this component name.
A bundle is a file, not something that runs, so there is no health axis to report and
no telemetry is published under cfg.bundleinfo.<bundleId>. Everything knowable about a
bundle is derived on read and lives on the configuration status above; whether its members
are running is a question about the members, answered by their own components.
Example¶
{
"type": "bundleinfo",
"id": "usbexport",
"bundle": "usbexport",
"description": "USB recording export, authored on colibri-07269992 2026-08-25",
"createdAt": "2026-08-25T09:30:00Z",
"digest": "sha256:ab12...",
"installedAt": "2026-08-25T11:02:41Z",
"configRefs": [
{ "ref": "sys.systemdmount.disk01", "bundleHash": "sha256:9f00...", "intentHash": "sha256:9f00...", "intentBundle": "usbexport" },
{ "ref": "trigger.trigger.stop_export", "bundleHash": "sha256:5566...", "intentHash": "sha256:5566..." },
{ "ref": "trigger.trigger.start_export", "bundleHash": "sha256:1122..." }
]
}
bridge¶
Component ref: sys.bridge.<iface>
Delete: system twin (RFC-023) - algol's config is withdrawn; the OS resource it mirrors is left exactly as it is.
A bridge interface (a NetworkManager bridge connection) whose members are ethernet ports and wlan access points, so wired and wireless clients share one segment. The ipv4 rules are the shared IPv4 configuration.
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be bridge. |
enabled |
bool | no | Enabled brings the connection up; disabled, it is left down. |
ipv4 |
object | no | IPv4 is the bridge's own IPv4 configuration. |
ipv4.method |
string | yes | How IPv4 is obtained. auto is a DHCP client, manual is static addresses, shared is a LAN (DHCP server, DNS relay, forwarding and masquerade), disabled turns IPv4 off. One of auto, manual, shared, disabled. |
ipv4.addresses |
string array | no | Static addresses (CIDR notation), the first of which is the interface's primary address. Compatible with auto, manual, shared (shared requires exactly one) and disabled (held while IPv4 is off). Gateways are not stored per-address in NetworkManager; use routes with 0.0.0.0/0 instead. At most 3 items. |
ipv4.routes |
array | no | Static routes, each a destination network and its gateway. shared forbids any. |
ipv4.routes[].network |
string | yes | Network is the destination in CIDR notation, 0.0.0.0/0 for the default route. Must be an IPv4 address in CIDR notation. |
ipv4.routes[].gateway |
string | yes | GatewayAddress is the IPv4 address of the next hop. Format: ipv4. |
ipv4.sharedDhcp |
string | no | Whether a shared LAN hands out addresses. server serves the hosts of the configured prefix, none serves nobody - what a segment that already has a DHCP server on it needs. An empty value is server. Only valid with method shared; the address, the DNS relay, the forwarding and the masquerade are the same under both values. One of server, none. |
ipv4.routeMetric |
int | no | The priority of this interface's routes, IPv4 and IPv6 alike. The lowest metric on the device wins, so a smaller number is a preferred uplink and this is what orders several of them - a cellular link ahead of a connected Ethernet socket, say. Unset leaves the choice to NetworkManager, which takes it from the device type (Ethernet 100, WiFi 600, cellular 700); setting it again is how a metric written into the profile elsewhere is overridden, and removing it returns the interface to that default. At least 1. |
Configuration status¶
The effective stage, as sys reports it.
| Status | Message (example) | Meaning | Cleared by |
|---|---|---|---|
DISABLED |
- | enabled = false |
enabling it |
OK |
- | enabled and NM reports the connection activated | the connection going inactive |
WAIT |
- | enabled and NM is still activating the connection. Every update of an enabled connection re-activates it, so this is the normal answer to an apply - typically for a few seconds | NM finishing the activation (OK), or failing it (ERROR) |
ERROR |
the connection cannot be activated/deactivated |
enabled but the NM connection is inactive - the activation failed or the link went down; for method=shared, NM's journal has the reason |
a corrected update, or the host condition that blocked activation |
ERROR |
cannot switch the DHCP server off for <iface>: ... |
ipv4.sharedDhcp = "none" where sys cannot install the drop-in: NetworkManager's drop-in directory is not configured, not there (not bind-mounted), not a directory, or not writable. Nothing was written and the connection was not touched, so the LAN keeps running as it was - still serving DHCP |
the mount, or dropping back to sharedDhcp = "server" |
The configurer refuses a shared ipv4 that does not have exactly one address, or that carries routes, and a sharedDhcp under any method other than shared, before the update reaches sys.
Application status¶
Typed status bridge is registered under this component name.
Published on every status tick, and again the moment status moves
(RFC-029 D1).
status is the same statement the configuration status above makes, on the axis a rule can
act on - a trigger's expr sees the telemetry contexts only, never the config one. The rest
is what the kernel has counted on the bridge interface itself, which is the sum over its
ports rather than any one of them, cumulative since the host booted. The whole counter block
is absent while the interface is not on the host: a zero counter and a device that is not
there read identically, and the second is the one worth seeing.
Example¶
DHCP client (the image default):
LAN. shared is opt-in: a shared bridge on a foreign segment is a rogue DHCP server.
channel¶
Component ref: <module>.channel.<id>
Delete: regular (RFC-023) - the component is destroyed and disappears from config ls after commit.
Configures a data channel with an ordered processor pipeline. Typical order: columnSelector -> resampler (optional) -> fft or windower(hop) + reducerExpr (optional) -> windower.
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be channel. |
description |
string | no | Description is a human-readable label for the channel. |
enabled |
bool | no | Enabled makes the channel produce data; the default is false. |
sourceRef |
component ref | no | SourceComponent is the data source the channel reads, e.g. bc.dsource.bc. |
processors |
array | no | Processors are the channel's processor pipeline, in order. Typically a columnSelector, then a resampler (optional), then an fft or a windower with a hop and a reducerExpr (optional), then a windower. Each entry is one of resampler, windower, columnSelector, frameValidator, fft, reducerExpr, discriminated by type. |
The legacy top-level fields columns, batchSize, syncTimeInit, and channelType have been removed; use processors (columnSelector, windower) instead.
Processors¶
columnSelector¶
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be columnSelector. |
id |
string | no | Id is an optional processor id, for component refs. |
columns |
array | yes | Columns are the column mappings, input column to output column, in output order. |
columns[].inputColumnName |
string | yes | InputColumnName is the name of the source column. |
columns[].name |
string | yes | Name is the name of the output column. |
columns[].range |
number array | no | Value range of the column, lower bound (inclusive), upper bound (exclusive). Omitted or [0,0): channelinfo reports the default range of the column's resulting data type. Exactly 2 items. |
columns[].transformations |
array | no | Transformations to apply to the column in order. These are composed as a composite transformation by default. Each entry is one of linear, dataType, clamp, composite, discriminated by type. |
channelType |
string | no | ChannelType is the semantic type of the channel, which fixes the columns it has to carry: Scalar (the default) any, CartesianCoordinate x, y and z of one numeric type, GeographicCoordinate lat, lon and alt as float64, GPSVelocity speed, track and climb, MonoPCMAudio16 audio as int16. One of Scalar, GeographicCoordinate, CartesianCoordinate, GPSVelocity, MonoPCMAudio16. |
The column mappings' transformations are described under Transformations; the channel types and the columns each requires under Channel types.
resampler¶
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be resampler. |
id |
string | no | Id is an optional processor id, for component refs. |
frequency |
number | yes | Frequency is the target output rate in Hz. Greater than 0. |
strategy |
string | yes | Strategy is how output samples are produced from the input: addRemove drops or repeats samples, linearInterpolation interpolates between neighbours, nearest takes the nearest input sample. One of addRemove, linearInterpolation, nearest. |
maxInputFrameGap |
number | no | MaxInputFrameGap is the largest gap between input frames in seconds; 0 means no limit. At least 0. |
windower¶
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be windower. |
id |
string | no | Id is an optional processor id, for component refs. |
windowSize |
int | yes | WindowSize is the number of samples per published window. Greater than 0. |
hop |
int | no | Hop is the window advance in samples. 0 (default) means hop == windowSize (tumbling windows); 0 < hop < windowSize emits overlapping/sliding windows (RFC-013). Must not exceed the value of WindowSize. Greater than 0. |
syncTimeInit |
bool | no | SyncTimeInit aligns the first window to a second boundary. |
Deprecated: batcher is the legacy name for windower. Persisted configs are rewritten to windower (batchSize -> windowSize); a live body that still sends type: "batcher" is accepted and stored as windower. That ingest fold is not the stored contract and will be removed in a later release. It cannot express hop.
Sliding windows (hop < windowSize)
An overlapped windower rewrites the time base: emitted frames carry the throughput frequency (input * windowSize / hop), so the stream stays monotonic and continuous while each sample is carried ~windowSize/hop* (intra-frame timestamps are synthetic; only frame starts are physical). Typical uses: framing for a reducerExpr reduction (a fresh window per hop samples), or bare - the per-batch stats over overlapping windows give rolling aggregates to data_status_val trigger expressions. See RFC-013.
reducerExpr¶
Stateless expression reducer (RFC-013): evaluates one expr-lang expression per output column over each incoming frame and emits one row per frame - a derived signal. Framing is the upstream windower's job (windowSize + hop); reducerExpr holds no cross-frame state. Wrong-shaped or mistyped expressions are rejected at config time (probe evaluation against the upstream-declared window size).
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be reducerExpr. |
id |
string | no | Id is an optional processor id, for component refs. |
columns |
array | yes | Columns defines the output columns in declared order (the wire layout of the derived channel), each computed by one expr-lang expression, typed float64. Column names must be unique within the processor. At least 1 item. |
columns[].name |
string | yes | Name is the output column's name; unique within the processor. |
columns[].expr |
string | yes | Expr is the expr-lang expression reducing the frame to one numeric value. |
env |
object | no | Env is a map of expression variables available in the expressions by bare name, injected as typed values before compilation (same convention as the trigger's streamExpr env). Keys are validated as ref parts and must not collide with the reserved col/window bindings. On the bound config these are resolved literals; binding slots (e.g. { "$value": "name" }) appear only in template specs before materialization (RFC-011) - properties are never implicitly available in expressions. |
env follows the same convention as the trigger's streamExpr match env.
Inside an expression, col.<name> is a column as a float64 array and window is the frame as a list of per-sample records:
[[processors]]
type = "windower"
windowSize = 5
hop = 1 # a fresh 5-sample window every sample
[[processors]]
type = "reducerExpr"
[processors.env]
scale = 1.0
[[processors.columns]]
name = "spike"
expr = "scale * max(flatten(map(window, { let a = #; map(window, { ((a.x-.x)**2 + (a.y-.y)**2 + (a.z-.z)**2) ** 0.5 }) })))"
[[processors]]
type = "windower" # re-batch 1-row results to publish cadence
windowSize = 100
Requires a known window size from upstream (a windower before it). End the chain with a plain tumbling windower - at hop = 1 an un-re-batched reduction publishes one message per sample.
fft¶
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be fft. |
id |
string | no | Id is an optional processor id, for component refs. |
windowSize |
int | yes | WindowSize is the FFT window size in samples; a power of 2. Greater than 0. |
windowFunction |
string | no | WindowFunction is the window applied before the transform: none, hann (the default), hamming, blackman or flatTop. One of none, hann, hamming, blackman, flatTop. |
output |
string | no | Output is what the bins carry: magnitude (the default), magnitudeDb, power or powerDb. One of magnitude, magnitudeDb, power, powerDb. |
normalize |
bool | no | Normalize divides the bins by the window coefficient sum. |
frameValidator¶
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be frameValidator. |
id |
string | no | Id is an optional processor id, for component refs. |
Transformations¶
A columnSelector column's transformations are applied in order, each one of the variants below.
linear¶
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be linear. |
scale |
number | yes | Scale is the factor the value is multiplied by; never 0. |
offset |
number | no | Offset is added after scaling. |
dataType¶
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be dataType. |
dataType |
string | no | DataType is the type the column is cast to: int16, uint16, int32, uint32, float32 or float64. One of unknown, uint32, int32, float64, float32, uint16, int16. |
clamp¶
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be clamp. |
min |
number | no | Min is the lower bound; omitted leaves that side unclamped. |
max |
number | no | Max is the upper bound; omitted leaves that side unclamped. |
composite¶
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be composite. |
transforms |
array | no | Transforms are applied in order; each step's output is the next step's input. Each entry is one of linear, dataType, clamp, composite, discriminated by type. |
Channel types¶
channelType |
Required columns | Data type constraints |
|---|---|---|
Scalar (default) |
any | any |
CartesianCoordinate |
x, y, z |
uniform numeric type |
GeographicCoordinate |
lat, lon, alt |
all float64 |
GPSVelocity |
speed, track, climb |
all float64 |
MonoPCMAudio16 |
audio |
int16 |
Application status¶
Typed status channel is registered under this component name.
Example: IMU accelerometer¶
type = "channel"
description = "IMU accelerometer"
enabled = true
sourceRef = "bc.dsource.bc"
[[processors]]
channelType = "CartesianCoordinate"
type = "columnSelector"
[[processors.columns]]
inputColumnName = "axl_x"
name = "x"
[[processors.columns]]
inputColumnName = "axl_y"
name = "y"
[[processors.columns]]
inputColumnName = "axl_z"
name = "z"
[[processors]]
frequency = 200
strategy = "linearInterpolation"
type = "resampler"
[[processors]]
syncTimeInit = true
type = "windower"
windowSize = 1000
{
"type": "channel",
"description": "IMU accelerometer",
"enabled": true,
"sourceRef": "bc.dsource.bc",
"processors": [
{
"type": "columnSelector",
"channelType": "CartesianCoordinate",
"columns": [
{
"inputColumnName": "axl_x",
"name": "x"
},
{
"inputColumnName": "axl_y",
"name": "y"
},
{
"inputColumnName": "axl_z",
"name": "z"
}
]
},
{
"type": "resampler",
"frequency": 200,
"strategy": "linearInterpolation"
},
{
"type": "windower",
"syncTimeInit": true,
"windowSize": 1000
}
]
}
channelinfo¶
Component ref: <module>.channelinfo.<id>
Read-only. Published by the module; not writable through the config store.
Derived from a channel config. Carries the applied processor pipeline plus the runtime shape the data source reports: frequency, window size, channel type, and columns.
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be channelinfo. |
description |
string | no | Description is a human-readable label for the channel. |
enabled |
bool | no | Enabled makes the channel produce data; the default is false. |
sourceRef |
component ref | no | SourceComponent is the data source the channel reads, e.g. bc.dsource.bc. |
processors |
array | no | Processors are the channel's processor pipeline, in order. Typically a columnSelector, then a resampler (optional), then an fft or a windower with a hop and a reducerExpr (optional), then a windower. Each entry is one of resampler, windower, columnSelector, frameValidator, fft, reducerExpr, discriminated by type. |
channelRef |
component ref | no | ChannelComponent is the writable channel this mirrors. |
frequency |
number | no | Frequency is the effective sample rate after the processors, in Hz. |
windowSize |
int | no | WindowSize is the effective window size after the processors, in samples. |
syncTimeInit |
bool | no | SyncTimeInit reports whether the first batch waits for a UTC second boundary. |
channelType |
string | no | ChannelType is the shape of a sample. One of Scalar, GeographicCoordinate, CartesianCoordinate, GPSVelocity, MonoPCMAudio16. |
columns |
array | no | Columns are the published columns. |
columns[].name |
string | yes | Name is the column name, a component ref part. |
columns[].dataType |
string | no | DataType is the column's sample type. One of unknown, uint32, int32, float64, float32, uint16, int16. |
columns[].range |
number array | no | Range is the column's value range as [min, max]. Exactly 2 items. |
Delete the channel and this mirror goes with it.
Application status¶
No typed status is registered under this component name.
A channel's runtime status is published under the channel name, <module>.channel.<id>, not under the mirror's.
deviceinfo¶
Component ref: sys.deviceinfo.main
Read-only. Published by the module; not writable through the config store.
One per device. The sys module mirrors the device's identity here from the files
written at provisioning
(RFC-003),
so every other module reads it through the normal property mechanism instead of mounting those
files. There is no write path: the identity changes only at a provisioning transition.
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be deviceinfo. |
deviceId |
string | yes | DeviceId is this device's id within its tenant. A component reference part, because it is a NATS subject segment in the cloud (algol.<tenantId>.device.<deviceId>...). |
tenantId |
string | yes | TenantId is the tenant this device belongs to, a component reference part for the same reason. |
serialNr |
string | yes | SerialNr is the serial number of the compute module itself, as the firmware reports it. It is not the hostname and not the serial of the carrier board the module is fitted to. |
hostname |
string | no | Hostname is the device's network hostname, as the host itself reports it. Empty when it could not be determined. Format: hostname. |
deviceId and tenantId are component-reference parts (^[a-zA-Z_][0-9a-zA-Z_]*$) because they
are NATS subject segments: together they address the device as
algol.<tenantId>.device.<deviceId>.... in the cloud.
hostname is the only field not read from a provisioning file: sys asks the host's
systemd-hostnamed over D-Bus for the effective hostname (what hostname prints on the host,
not the container's). It is read once at startup like the rest of the identity.
serialNr and hostname are independent facts and must stay that way. The serial comes from
the device tree (/sys/firmware/devicetree/base/serial-number), where the firmware writes the
compute module's own number; the hostname is a name the host is given, and is expected to stop
containing the module serial. Deriving one from the other silently redefines it the day the
naming convention changes - including in the X-Flow-Serial header of recordings already
written.
This is the source almost every configRef to device identity points at - see
gate, s3upload and usbexport:
[[properties]]
type = "configRef"
name = "deviceId"
ref = "sys.deviceinfo.main"
jsonPath = "$.deviceId"
timeout = -1
timeout = -1 (wait indefinitely) is the right setting for all of them: sys.deviceinfo.main is
owned by another module and need not be published yet when the referencing config first resolves.
Configuration status¶
The effective stage, as sys reports it. There is no intent stage: the component is
module-sourced and read-only.
| Status | Message (example) | Meaning | Cleared by |
|---|---|---|---|
OK |
- | The whole identity was read | - |
WAIT |
unresolved: <fields> |
A part of the identity is not there: the fields are named in the order deviceId, tenantId, serialNr, hostname |
a restart of sys once the missing part exists |
WAIT |
unresolved: <fields>: <reason> |
As above, with why the read failed appended - an unwritten or unparseable identity file, or a host that did not answer for the hostname | the same |
A missing part holds the whole component in WAIT, and a configRef reads no value from a
source that is not OK - so a device whose host does not report a hostname keeps gate waiting
on deviceId too. The named fields in the message are what tells the two cases apart.
Never reached: ERROR, because nothing here is a rejected configuration - the identity is read,
not applied. DISABLED has no meaning for a type without an enable concept, and CONFIG_UPDATE /
CONFIG_DELETE are refused outright as the type is read-only.
Application status¶
No typed status is registered under this component name.
The identity is a set of values, not something that runs. sys's own health is on
sys.app.main and its host metrics on sys.system.main.
Example¶
dsource¶
Component ref: <module>.dsource.<id>
Read-only. Published by the module; not writable through the config store.
Typically <module>.dsource.main. Published by a data-source module (gps, retx, bc, ...) to advertise the hardware inputs it can feed into channel configs.
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be dsource. |
frequency |
number | yes | Frequency is the native sample rate in Hz. Fixed for most modules; where it is configured (gpsapp), a change republishes this config and every channelinfo on the source with the new rate. At least 0. |
inputChannels |
array | yes | InputChannels are the hardware inputs, one per column a channel can select. |
inputChannels[].name |
string | yes | Name is the input's column name, a component ref part. |
inputChannels[].dataType |
string | no | DataType is the input's sample type. One of unknown, uint32, int32, float64, float32, uint16, int16. |
channelModuleId |
string | yes | ChannelModuleId is the module id the source's channel and channelinfo components are published under. |
Application status¶
Typed status dsource is registered under this component name.
A source's own counters - the channels it drives and whether its ingress is failing - are published under this name by its module.
dns¶
Component ref: sys.dns.main
Delete: singleton (RFC-023) - the component is reset to the module's startup default and keeps answering.
Singleton. The host's DNS servers, delivered as a systemd-resolved drop-in.
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be dns. |
servers |
string array | no | Servers are the DNS server addresses, IPv4 or IPv6. |
Application status¶
No typed status is registered under this component name.
The config-status axis alone; the module's health is on sys.app.main.
Example¶
ethernet¶
Component ref: sys.ethernet.<iface>
Delete: system twin (RFC-023) - algol's config is withdrawn; the OS resource it mirrors is left exactly as it is.
An Ethernet port (a NetworkManager wired connection): a member of a bridge through bridgeRef, or a standalone port with its own ipv4 under the shared IPv4 configuration.
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be ethernet. |
enabled |
bool | no | Enabled brings the connection up; disabled, it is left down. |
bridgeRef |
component ref | no | The sys.bridge this port is a member of (e.g. "sys.bridge.br0"). A member port has no IP configuration of its own: ipv4 must be omitted. When the key is absent the port is standalone, with its own ipv4. |
ipv4 |
object | no | IPv4 is the standalone port's own IPv4 configuration; forbidden when bridgeRef is set, since a member has none of its own. |
ipv4.method |
string | yes | How IPv4 is obtained. auto is a DHCP client, manual is static addresses, shared is a LAN (DHCP server, DNS relay, forwarding and masquerade), disabled turns IPv4 off. One of auto, manual, shared, disabled. |
ipv4.addresses |
string array | no | Static addresses (CIDR notation), the first of which is the interface's primary address. Compatible with auto, manual, shared (shared requires exactly one) and disabled (held while IPv4 is off). Gateways are not stored per-address in NetworkManager; use routes with 0.0.0.0/0 instead. At most 3 items. |
ipv4.routes |
array | no | Static routes, each a destination network and its gateway. shared forbids any. |
ipv4.routes[].network |
string | yes | Network is the destination in CIDR notation, 0.0.0.0/0 for the default route. Must be an IPv4 address in CIDR notation. |
ipv4.routes[].gateway |
string | yes | GatewayAddress is the IPv4 address of the next hop. Format: ipv4. |
ipv4.sharedDhcp |
string | no | Whether a shared LAN hands out addresses. server serves the hosts of the configured prefix, none serves nobody - what a segment that already has a DHCP server on it needs. An empty value is server. Only valid with method shared; the address, the DNS relay, the forwarding and the masquerade are the same under both values. One of server, none. |
ipv4.routeMetric |
int | no | The priority of this interface's routes, IPv4 and IPv6 alike. The lowest metric on the device wins, so a smaller number is a preferred uplink and this is what orders several of them - a cellular link ahead of a connected Ethernet socket, say. Unset leaves the choice to NetworkManager, which takes it from the device type (Ethernet 100, WiFi 600, cellular 700); setting it again is how a metric written into the profile elsewhere is overridden, and removing it returns the interface to that default. At least 1. |
Configuration status¶
The effective stage, as sys reports it. Same activation rows as bridge. Membership adds:
| Status | Message (example) | Meaning | Cleared by |
|---|---|---|---|
ERROR |
the connection cannot be activated/deactivated |
no such bridge, or the bridge is disabled | a bridge that exists and is enabled |
ERROR |
unknown bridge <ref> |
bridgeRef does not name a sys-tracked bridge |
a tracked sys.bridge.<id> |
ERROR |
ethernet master "..." does not map to a tracked bridge |
NM connection.master does not match a sys-tracked bridge |
a tracked bridge whose interface-name, id, or UUID matches |
The configurer refuses a bridgeRef that is not a <module>.bridge.<id> ref, and refuses bridgeRef set together with ipv4, before the update reaches sys. sys reports ERROR (no panics) when the ref cannot be mapped to a tracked bridge, or when Get cannot map NM's master.
Application status¶
Typed status ethernet is registered under this component name.
Published on every status tick, and again the moment status moves
(RFC-029 D1).
status is the same statement the configuration status above makes, on the axis a rule can
act on - a trigger's expr sees the telemetry contexts only, never the config one. The rest
is what the kernel has counted on the interface the connection is bound to, cumulative since
the host booted, so a decrease means the host rebooted. The counters are keyed by the
interface the connection was activated on rather than by the profile, so a config whose id
differs from the kernel's device name still reports the right device; errorsRxCounter
rising alongside traffic points at the link - cabling, a duplex mismatch - where drops point
at the host not keeping up. The whole counter block is absent while the interface is not on
the host: a zero counter and a device that is not there read identically, and the second is
the one worth seeing.
Example¶
Member of br0:
Standalone uplink (DHCP client):
Standalone static IP:
wlan¶
Component ref: sys.wlan.<iface>
Delete: system twin (RFC-023) - algol's config is withdrawn; the OS resource it mirrors is left exactly as it is.
An algol-managed WiFi connection (a NetworkManager 802-11-wireless profile). As a client it joins a network and is an uplink, by default at NetworkManager's route metric 600 - after Ethernet, before cellular - and at whatever ipv4.routeMetric says where the order has to be something else. As an access point it serves one and is a LAN: either ipv4.method = "shared" on the interface, or a member of a bridge so wireless and wired clients share one segment. Same bridgeRef / ipv4 pair as ethernet.
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be wlan. |
enabled |
bool | no | Enabled brings the connection up; disabled, it is left down. A disabled config may leave mode, ssid and security out. |
mode |
string | when enabled | Operating mode: client joins the network named by ssid and is an uplink (NetworkManager route metric 600); ap serves that network as an access point, a LAN with ipv4 method shared or a member of a sys.bridge. One of client, ap. |
ssid |
string | when enabled | The network name to join (client) or to serve (ap), 1 to 32 bytes. 1 to 32 characters. |
security |
string | when enabled | How the network is secured: open (no encryption) or wpaPsk (WPA2 personal, a pre-shared key). One of open, wpaPsk. |
pskRef |
component ref | no | The pre-shared key for security wpaPsk, as a reference to a secret of kind string ("cfg.secret.<id>"): its value is a passphrase of 8 to 63 printable ASCII characters, or the 256-bit key as 64 hex digits. Required with wpaPsk, must be omitted for open. |
bridgeRef |
component ref | no | The sys.bridge this access point is a member of (e.g. "sys.bridge.br0"), so wireless and wired clients share one segment. Requires mode ap. A member has no IP configuration of its own: ipv4 must be omitted. When the key is absent the interface is standalone, with its own ipv4. |
ipv4 |
object | no | IPv4 is the standalone interface's own IPv4 configuration; forbidden when bridgeRef is set, since a member has none of its own. |
ipv4.method |
string | yes | How IPv4 is obtained. auto is a DHCP client, manual is static addresses, shared is a LAN (DHCP server, DNS relay, forwarding and masquerade), disabled turns IPv4 off. One of auto, manual, shared, disabled. |
ipv4.addresses |
string array | no | Static addresses (CIDR notation), the first of which is the interface's primary address. Compatible with auto, manual, shared (shared requires exactly one) and disabled (held while IPv4 is off). Gateways are not stored per-address in NetworkManager; use routes with 0.0.0.0/0 instead. At most 3 items. |
ipv4.routes |
array | no | Static routes, each a destination network and its gateway. shared forbids any. |
ipv4.routes[].network |
string | yes | Network is the destination in CIDR notation, 0.0.0.0/0 for the default route. Must be an IPv4 address in CIDR notation. |
ipv4.routes[].gateway |
string | yes | GatewayAddress is the IPv4 address of the next hop. Format: ipv4. |
ipv4.sharedDhcp |
string | no | Whether a shared LAN hands out addresses. server serves the hosts of the configured prefix, none serves nobody - what a segment that already has a DHCP server on it needs. An empty value is server. Only valid with method shared; the address, the DNS relay, the forwarding and the masquerade are the same under both values. One of server, none. |
ipv4.routeMetric |
int | no | The priority of this interface's routes, IPv4 and IPv6 alike. The lowest metric on the device wins, so a smaller number is a preferred uplink and this is what orders several of them - a cellular link ahead of a connected Ethernet socket, say. Unset leaves the choice to NetworkManager, which takes it from the device type (Ethernet 100, WiFi 600, cellular 700); setting it again is how a metric written into the profile elsewhere is overridden, and removing it returns the interface to that default. At least 1. |
pskRef is required with security = "wpaPsk" and forbidden with open; it names a secret of kind string. A disabled config may leave mode, ssid and security out. A field left out is not written, so NetworkManager keeps its current value - including a stored key.
The key never travels the configuration API (RFC-001): pskRef names a secret whose value sys fetches when it applies the config and writes into the NetworkManager profile as a system-owned secret. The effective configuration reports the reference, never the key. A value set, rotated or deleted re-applies the config: a changed key is written and the link re-activated (an unchanged one is left alone); a secret that is deleted or has no value has its key withdrawn from the profile and the link taken down, until a value exists again. A configurer that does not answer changes nothing - the key stays applied.
Configuration status¶
The effective stage, as sys reports it. Same activation rows as bridge and the membership rows of ethernet, with wlan master "..." in place of ethernet master "...". The radio adds:
| Status | Message (example) | Meaning | Cleared by |
|---|---|---|---|
ERROR |
the connection cannot be activated/deactivated: <reason> |
the activation failed and NetworkManager said why, in its own wording: the Wi-Fi network could not be found (out of range, switched off, wrong ssid), secrets were required, but not provided or one of the supplicant reasons (the key was refused), a DHCP reason (associated, no address). A reason NM has no wording for is reported as NetworkManager reason <n> |
the network, the key, or the host condition behind the reason |
ERROR |
the connection cannot be activated/deactivated: the wireless device is not available |
the wireless device the profile binds is not on the host (powered off, unplugged, its driver never came up), or it is there and NetworkManager has it as unavailable: switched off (rfkill), unmanaged, or without firmware. The one sentence here that is not NetworkManager's wording - it states no reason for either - and it replaces the reason of the last attempt, which was made while the radio was still usable | the device coming back, or being switched on |
ERROR |
the connection cannot be activated/deactivated |
enabled but inactive with no reason from NetworkManager - the link went down without a failed activation behind it, or the device does not support ap mode |
a corrected update, or the host condition that blocked activation |
ERROR |
unsupported wireless mode "..." |
the NM profile is in a mode sys does not model (adhoc, mesh) |
a profile in infrastructure or ap mode |
ERROR |
unsupported key-mgmt "..." |
the NM profile uses a security sys does not model (WPA3 sae, enterprise wpa-eap) |
a profile with no security section or wpa-psk |
The key adds the consumer rows of RFC-001 D8. A row here is the state of the config until the next apply, so a status read later reports the same:
| Status | Message (example) | Meaning | Cleared by |
|---|---|---|---|
WAIT |
secret cfg.secret.wifi_site: not declared |
pskRef names a secret that does not exist; a key applied earlier was withdrawn from the profile and the link is down |
declaring and setting it (the re-dispatch re-applies) |
WAIT |
secret cfg.secret.wifi_site: no value |
declared, not set; same withdrawal | algol secret set |
WAIT |
secret cfg.secret.wifi_site: unavailable |
the configurer did not answer; the key the profile holds stays applied | the configurer coming back (its startup republish re-applies a config resting in WAIT) |
ERROR |
secret cfg.secret.wifi_site is a bytes secret, needs string |
wrong kind; nothing written | a secret of kind string |
ERROR |
secret cfg.secret.wifi_site: not a WPA pre-shared key (8 to 63 printable ASCII characters, or 64 hex digits) |
the value does not fit a WPA key; nothing written | a valid value |
ERROR |
secret cfg.secret.wifi_site: not admitted |
the declaration does not list sys.wlan - normally refused at ingest; reaches sys only if the two got out of step |
the declaration admitting sys.wlan, or the reference removed |
The configurer refuses, before the update reaches sys: a bridgeRef that is not a <module>.bridge.<id> ref, bridgeRef together with ipv4, bridgeRef with a mode other than ap, a pskRef with security = "open", a missing pskRef with wpaPsk, a pskRef that does not name a secret component or names one whose declaration does not admit sys.wlan, an ssid over 32 bytes, and the shared ipv4 rules of bridge.
Application status¶
Typed status wlan is registered under this component name.
Published on every status tick, and again the moment status moves
(RFC-029 D1).
status is the same statement the configuration status above makes, on the axis a rule can
act on - a trigger's expr sees the telemetry contexts only, never the config one. The
interface counters are those of ethernet, on the radio's netdev.
wlanFault is why the connection is not up, as the one value a rule matches on: none,
ssidNotFound, authFailed, ipConfigFailed, deviceUnavailable, other. It is
what makes an ERROR actionable - a network that is out of range and a key that was refused
are the same status and want different responses - and wlanStateReason carries
NetworkManager's own reason number beside it (53 for ssidNotFound), which is the whole
reading behind an other. Both are absent from the profile and from the connection: NM
states them on the device, on the transition into its failed state, and it leaves that
state again within moments. So sys takes the reason from the transition and holds it, and
what spends it is the next successful activation or a fresh one of algol's own - NM's own
autoconnect retries keep it, since they retry the same profile. A connection that is down
without a failed attempt behind it (never enabled, taken down by an operator) reports none.
deviceUnavailable is the one value that does not come from a failed attempt, and it
outranks the held reason. A device that goes away (the radio powered off, the stick pulled)
never transitions into NM's failed state, and a radio that is switched off rests in NM's
unavailable state, where no activation is ever attempted; nothing about either is ever
held. Without this, a radio that was powered off would go on reporting ssidNotFound from
the last attempt made through it, which reads as a statement about the network, and one that
was switched off would report none, which reads as nothing being wrong. So an ERROR
connection is mapped to its device the way its traffic counters are - the profile's
connection.interface-name, or the component id where the profile names none, so
sys.wlan.wlan0 is wlan0 - and NM is asked whether it has that wireless device and in
what state. Not there, or there in NM's UNAVAILABLE or UNMANAGED state, is
deviceUnavailable, with no wlanStateReason beside it - NM stated no number for it. Only
a definite answer counts: a device listing that fails, or one with a device in it that could
not be read and that might be the radio, leaves the held reason in place and logs a warning
saying so. A connection that is up is not asked, and a disabled one reports none as it
does for every other reason.
wlanFault is one of two things emitted on change (status is the other): a fault moves
while the status does not - both readings are ERROR - and a consumer that shows the reason
would otherwise hold the previous one for a whole interval. deviceUnavailable reaches that
emit by its own route, since it is derived rather than latched: NetworkManager announcing a
device appearing or going away, or a device crossing into or out of its unavailable state,
is what prompts it, and what is compared is the answer to "is my radio there and usable" for
the previous listing of wireless devices against the answer for the current one. So a radio
that is powered off or switched off is reported when it goes, and the veth pair a container
brings with it emits nothing.
What is particular to wlan is the radio block - ssid, bssid, signalStrength,
frequency, bitrate - read from the access point the device is associated with:
- The reported
ssidis what the link is on, which is not the configuredssidwhile a changed profile has not been applied yet. It is absent on a hidden network, where the access point answers with an empty one;bssidis what identifies the link there. bssidis the answer to "which access point", where several share onessid. A device that roamed and one that did not look the same without it.signalStrengthis a percentage as NetworkManager reports it, not dBm and not convertible to one.0is a reading (no usable signal), not an absence.- The whole block is absent while the device is associated with nothing, which is the normal
state of a wlan whose link is down.
bitratecomes from the device rather than from the association, so it can be there when the rest is not.
The radio is sampled at collection time and is not what triggers an on-change emit -
signalStrength drifts continuously, and emitting on it would make the seam a second,
faster tick. A rule that watches signal quality reads the periodic samples. wlanFault is
the opposite case and does trigger one: it is a state, not a sample.
Example¶
Client, an uplink on the site network (DHCP):
Access point, its own LAN. shared is opt-in, as on bridge:
Access point bridged into br0, one segment with the Ethernet ports:
dummyintf¶
Component ref: sys.dummyintf.<iface>
Delete: system twin (RFC-023) - algol's config is withdrawn; the OS resource it mirrors is left exactly as it is.
An algol-managed dummy network interface (a NetworkManager dummy connection). Same IPv4 shape as ethernet and bridge, including method=shared.
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be dummyintf. |
enabled |
bool | no | Enabled brings the connection up; disabled, it is left down. |
ipv4 |
object | no | IPv4 is the interface's IPv4 configuration; omitted, the interface has none. |
ipv4.method |
string | yes | How IPv4 is obtained. auto is a DHCP client, manual is static addresses, shared is a LAN (DHCP server, DNS relay, forwarding and masquerade), disabled turns IPv4 off. One of auto, manual, shared, disabled. |
ipv4.addresses |
string array | no | Static addresses (CIDR notation), the first of which is the interface's primary address. Compatible with auto, manual, shared (shared requires exactly one) and disabled (held while IPv4 is off). Gateways are not stored per-address in NetworkManager; use routes with 0.0.0.0/0 instead. At most 3 items. |
ipv4.routes |
array | no | Static routes, each a destination network and its gateway. shared forbids any. |
ipv4.routes[].network |
string | yes | Network is the destination in CIDR notation, 0.0.0.0/0 for the default route. Must be an IPv4 address in CIDR notation. |
ipv4.routes[].gateway |
string | yes | GatewayAddress is the IPv4 address of the next hop. Format: ipv4. |
ipv4.sharedDhcp |
string | no | Whether a shared LAN hands out addresses. server serves the hosts of the configured prefix, none serves nobody - what a segment that already has a DHCP server on it needs. An empty value is server. Only valid with method shared; the address, the DNS relay, the forwarding and the masquerade are the same under both values. One of server, none. |
ipv4.routeMetric |
int | no | The priority of this interface's routes, IPv4 and IPv6 alike. The lowest metric on the device wins, so a smaller number is a preferred uplink and this is what orders several of them - a cellular link ahead of a connected Ethernet socket, say. Unset leaves the choice to NetworkManager, which takes it from the device type (Ethernet 100, WiFi 600, cellular 700); setting it again is how a metric written into the profile elsewhere is overridden, and removing it returns the interface to that default. At least 1. |
Configuration status¶
Same activation rows as bridge. The configurer refuses a shared ipv4 that does not have exactly one address, or that carries routes, before the update reaches sys.
Application status¶
No typed status is registered under this component name.
The config-status axis alone; the module's health is on sys.app.main.
Example¶
file¶
Component ref: <module>.file.<id>
Delete: regular (RFC-023) - the component is destroyed and disappears from config ls after commit.
References file content via a data:, file:, or http(s): URL.
- Inline
data:URLs are limited to 64 KiB of decoded content, the same cap on every path: a config update, a manifest import, a bundle activation and a startup config read from disk. Unranked content is written to the volatile file root, which a reboot empties and the config rewrites; content at acachePriorityabovenoneis stored in the artifact cache on the non-volatile root instead (see below). file:URLs reference an absolute path under the public file root (in place) or the non-volatile managed root. Containment is checked against the path the symlinks resolve to, so a link under a root that points outside every root is refused; one that points back inside a root is allowed.http:/https:URLs are downloaded into the artifact cache under the non-volatile file root, at<non-volatile>/cache/<componentRef>/<algorithm>-<hex>(for example/var/algol/sys/files/cache/sys.file.myapp_rootfs/sha256-ab12...), and require adigest(see below).fileinfo.filenamepoints at that path, which never changes for as long as the artifact exists. The download is skipped when that artifact already exists and hashes to the configureddigest- the lookup is by the component's own ref and the digest, so one component never obtains an artifact another one fetched. On the first apply after an upgrade, a file a pre-cache build installed at<non-volatile>/<componentId>is moved into the cache if it hashes to the pin, so the upgrade itself downloads nothing. While downloading, config status isWAITon both thefileand itsfileinfo, so asystemdscriptorsystemdappsourcingfileinfo.filenamethrough aconfigRefis held back until the bytes are on disk. Transient failures (unreachable host, HTTP 5xx) are retried with exponential backoff starting at 1s and capped at 10 minutes;maxDownloadAttemptsbounds the attempts (0or omitted = unbounded). Re-applying an unchanged config while it downloads joins the download rather than restarting it, and during a backoff retries at once; a changedurlrestarts. Permanent failures (4xx, TLS certificate rejection, digest mismatch, size gates, a cache that cannot be freed) are not retried. Per-file and directory size limits are sys CLI flags (--max-file-download-bytes, default 100 MiB;--max-non-volatile-file-dir-bytes, default 200 MiB, which covers the cache).
cachePriority decides what happens to the artifact once no config names it. none (the default) means it goes when its config is deleted or moves to another digest - exactly the pre-cache behaviour. low, normal and high mean it survives, and under space pressure is evicted in that order, least recently stamped first. Two rules follow from that. An artifact a live config names is never evicted, whether that config is enabled or disabled: disabling is not deleting, and re-enabling is a cache hit. And once an artifact has been ranked above none it never returns to none - an edit or a delete demotes it to low rather than making it collectable, so an unrelated re-apply of the config cannot cost an artifact that may not be fetchable again. fileinfo.cachePriority reports the level the artifact actually has, which is what differs from the config's field in exactly that case. A component keeps at most ten of its own artifacts at rank (a rollback to a previous digest is a hit); older ones are demoted to low. A new download that needs room evicts orphaned artifacts ranked at or below its own cachePriority; when that cannot free enough, the config is refused with ERROR naming the shortfall - raising its cachePriority widens what it may displace. Setting cachePriority on a file: URL is refused, because that artifact is left where it is and nothing is stored for it. Inline data: content takes a level like a downloaded one: ranked it is kept in the artifact cache on the non-volatile root and survives a wiped config directory, unranked it lives on the volatile root, which a reboot empties and the config rewrites. The retention record is the artifact's own marker file beside it (<artifact>.<level>), never a config, so it survives the config's deletion and a power cut alike. See RFC-032.
The digest is mandatory for http: / https: and it is the whole security model. A downloaded file can be named by a systemdscript and executed as root, so the digest is what ties the bytes that arrive to the bytes intended - and it is never derived from the response. Because the digest carries that weight, the transport does not have to: plain http: is permitted, and redirects are followed even when they downgrade to http:. One caveat that the digest does not cover - credentials embedded in the URL (https://user:token@host/...) become an Authorization: Basic header and travel in clear over http:, and the URL itself is stored on disk and shown by config info.
For data: and file: URLs a digest is optional: it is verified when given, and otherwise filled in from the content that was read.
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be file. |
url |
string | when enabled | URL is the file's source: a data: URL carrying the content inline, a file: URL naming a path under the public or the non-volatile file root, or an http: / https: URL, which is downloaded into the artifact cache and needs a digest. |
digest |
string | no | Digest is the content digest as algorithm:hex (sha256:<64 hex> or md5:<32 hex>). Mandatory for an http(s) url, where it is what ties the bytes that arrive to the bytes intended; optional otherwise, verified when given and filled in from the content when not. Must be a content digest, algorithm:hex (see lib/messaging/digest). |
maxDownloadAttempts |
int | no | MaxDownloadAttempts bounds the http(s) attempts on transient failures; 0 or omitted is unbounded. At least 0. |
cachePriority |
string | no | CachePriority is how long this component's artifact is kept once no config names it any more: none (the default) means it goes with its config, the other levels mean it survives and is evicted in that order. A ranked http(s) or data: artifact is kept in the artifact cache on the non-volatile file root; an unranked data: file lives on the volatile root instead, which a reboot empties and the config rewrites. A file: url is left where it is and is never stored, so any value but none is refused there. One of none, low, normal, high. |
enabled |
bool | no | Enabled makes the file available; the default is false. |
description |
string | no | Description is a human-readable description of the file. |
Configuration status¶
The effective stage, as sys reports it. The fileinfo mirror reports exactly what its file reports, so the pair can never disagree about the same file - that is the readiness barrier a configRef consumer relies on.
| Status | Message (example) | Meaning | Cleared by |
|---|---|---|---|
WAIT |
downloading |
the artifact is being fetched | the download completing or failing terminally |
WAIT |
downloading (retry N in D: <cause>) |
a transient failure (unreachable host, HTTP 5xx) is being retried with backoff | success, or maxDownloadAttempts being reached |
ERROR |
download failed: unexpected status 4xx |
the origin refused | a corrected url |
ERROR |
download failed: <tls error> |
the server's certificate was rejected - a decision, not a hiccup | a corrected url or a fixed certificate |
ERROR |
digest mismatch, expected X, got Y |
the bytes that arrived are not the bytes pinned; nothing was installed | a corrected digest or url |
ERROR |
download size N exceeds maximum of M bytes |
above --max-file-download-bytes |
a smaller artifact or a raised flag |
ERROR |
non-volatile file directory size N exceeds maximum of M bytes, of which the artifact cache holds K: cache could not free ... |
a download could not be admitted: eviction could not make room, because everything else is named by a live config or ranked higher - or because most of the directory is not cache at all, which is what the third number says | a raised cachePriority, a deleted competitor, or a raised --max-non-volatile-file-dir-bytes |
ERROR |
non-volatile file directory size would exceed maximum of N bytes, of which the artifact cache holds K: cache could not free ... |
ranked inline data: content could not be admitted to the cache; the same eviction rule as a download, decided against the exact size before anything is written |
a raised cachePriority, a deleted competitor, or a raised --max-non-volatile-file-dir-bytes |
ERROR |
digest is required for an http(s) url ... |
an http(s) url without a pin |
a digest |
ERROR |
cachePriority "high" is not supported for a file: url; ... |
a retention level on content sys never stores: a file: artifact is left where it is. data: content takes a level like a downloaded one |
none, or a url whose content is stored (data:, http(s):) |
ERROR |
no url |
enabled without a url |
a url |
ERROR |
discovered file: <path> |
a file found in the volatile root at startup that no config accounts for | an operator config for that id, or removing the file |
ERROR |
component id "x" exists in a higher-priority root; ... assigned suffix |
two roots hold a file of the same name; the lower one got a _N suffix |
removing one of them |
OK |
- | the file is installed and fileinfo.filename names it |
- |
The configurer refuses three things at config update before they reach sys at all, so they never appear as an effective status: an inline data: URL above the 64 KiB cap, an http(s) url without a digest, and a cachePriority on a file: URL. The same three are refused on the way in from a startup config on disk, where they surface as a load error on the startup stage. sys checks the last two again itself, as the backstop for an update that arrives wrapped in a resolution envelope.
Application status¶
No typed status is registered under this component name.
sys.file.* and sys.fileinfo.* carry no health axis: everything about a file's state is on the config axis above. fileinfo.cachePriority is the artifact's actual retention level, fileinfo.filename is the path a consumer binds, and fileinfo.source / fileinfo.sizeBytes describe the file that is installed there.
The artifact cache as a whole reports on <module>.filecache.main, since an artifact that outlived its config has no component of its own: cacheBytes, cacheArtifacts, cacheEvictionCounter and dirBytes (see telemetry). dirBytes is the whole non-volatile file directory, which is what --max-non-volatile-file-dir-bytes bounds: a file: payload under the managed root counts against the budget without being in the cache, so it is the number that says whether the next download has room.
Example¶
Example: downloaded artifact¶
{
"type": "file",
"enabled": true,
"url": "https://artifacts.example.com/sensor-bridge-v2.3.squashfs",
"digest": "sha256:9f2b7c1e4d3a8f6b0c5e2a7d4b1f8e3c6a9d0b7e4f1c8a5d2b9e6f3c0a7d4b1e",
"maxDownloadAttempts": 5,
"cachePriority": "high",
"description": "Sensor bridge application image"
}
With cachePriority: high the image outlives this config: deleting sys.file.sensor_bridge - by hand, or by a bundle activation that no longer lists it - leaves the artifact in the cache, and re-creating the config is served from it without a download.
fileinfo¶
Component ref: <module>.fileinfo.<id>
Read-only. Published by the module; not writable through the config store.
Derived from a file config. This is what a configRef consumer binds: $.filename is the path on disk once the file is ready.
source and sizeBytes are readings of the file that is installed, not a copy of the config: source is the scheme of the url its file carries (data, file, http, https), and sizeBytes is the size on disk. They are here so that a listing can say where a file came from and how big it is without reading the file body, which for inline content is the content itself.
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be fileinfo. |
filename |
string | no | Filename is the resolved path on disk, in the artifact cache or the file root - what a configRef consumer binds once the file is ready. |
fileRef |
component ref | no | FileComponent is the writable file config this mirrors. |
digest |
string | no | Digest is the content digest as algorithm:hex, verified or filled in from the content. |
cachePriority |
string | no | CachePriority is the artifact's actual retention level, which is not always what the file config asked for: once retained it never falls back to none, and older artifacts of the same component may be demoted. Empty for a file that is not stored under a key at all - so unlike the file config's field, empty and "none" are different statements here. One of none, low, normal, high. |
source |
string | no | Source is where the content came from, as the scheme of the url the file config carries: "data" for content the config carries inline, "file" for a path on the host, "http" or "https" for a download. Empty for a file the module discovered rather than one an operator configured. One of data, file, http, https. |
sizeBytes |
int | no | SizeBytes is the size of the file named by filename, in bytes, as it is on disk. Zero for a config that has no file installed yet. |
enabled |
bool | no | Enabled mirrors the file config's enabled. |
description |
string | no | Description mirrors the file config's description. |
Config status matches the parent file exactly, so a configRef on fileinfo cannot see a ready file the file still reports as downloading. See the file configuration status table.
Application status¶
No typed status is registered under this component name.
The file's state is the file config axis.
flow¶
Component ref: flower.flow.<id>
Delete: regular (RFC-023) - the component is destroyed and disappears from config ls after commit.
Sets the default channel list used when starting a measurement via the flower module.
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be flow. |
defaultChannelRefs |
component ref array | no | DefaultChannelComponents are the channels a measurement records by default. |
Commands¶
| Command | Request | Description |
|---|---|---|
flower.flow.<id>.measurement_event_publish |
JSON | Hands the flow a measurement event: without a measurementRef it starts a new measurement, with one it extends or stops the running one, and an event nothing is running for is ignored. The reply says which, and names the measurement running now. |
Application status¶
Typed status flow is registered under this component name.
The measurement lifecycle - whether a Measurement is active - is published on flower.flow.main, on the tick and on every change.
Example¶
gate¶
Component ref: gate.gate.main
Delete: regular (RFC-023) - the component is destroyed and disappears from config ls after commit.
Controls which telemetry the gate module forwards to a remote NATS server.
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be gate. |
statusForwardFilter |
string array | no | StatusForwardFilter selects the components whose status-context telemetry is forwarded, as component filters ("sys.gsm.*"; trailing segments may be left off). |
dataStatusForwardFilter |
string array | no | DataStatusForwardFilter selects the components whose data_status-context telemetry is forwarded, as component filters. |
eventForwardFilter |
string array | no | EventForwardFilter selects the components whose event2-context events are forwarded, as component filters. |
statusForwardInterval |
int | no | StatusForwardInterval batches the status forwarding: 0 forwards every status message as it arrives, a value in seconds forwards the latest status of every selected component once per interval instead. A value below 10 is refused. |
dataStatusForwardInterval |
int | no | DataStatusForwardInterval batches the data-status forwarding the same way: 0 forwards every message as it arrives, a value in seconds forwards the latest data status of every selected component once per interval. A value below 10 is refused. |
enabled |
bool | no | Enabled switches the forwarding on. A disabled config may be an incomplete shell: deviceId and tenantId are required only when enabled. |
remoteNatsUrl |
string | no | RemoteNatsURL is the remote NATS server the entry connects to (nats://host:4222); nothing is forwarded without one. |
remoteNatsCredsRef |
component ref | no | The NATS credentials the remote connection authenticates with, as a reference to a secret of kind bytes ("cfg.secret.<id>") whose value is the credentials file's bytes. Omit it to connect without credentials, which only a remote that requires none accepts. |
deviceId |
string | when enabled | DeviceId identifies this device within the tenant. It is the fourth token of every cloud subject this entry publishes on (algol.<tenantId>.device.<deviceId>.<moduleId>.<context>.<componentName>.<componentId>), so it must be what the remote credentials authorize - a value the credentials do not cover is accepted here and then rejected by the remote server on every publish. |
tenantId |
string | when enabled | TenantId names the tenant this device publishes under, the second token of every cloud subject this entry publishes on. Same credential constraint as deviceId. |
deviceId and tenantId name the cloud subject this entry publishes on
(algol.<tenantId>.device.<deviceId>....), so they must be what remoteNatsCredsRef authorizes -
a mismatch connects and then has every publish rejected. They are per entry, like the credentials
beside them; binding all three from sys.deviceinfo.main is what keeps several entries in step.
An entry upgraded from a version that named a credentials file points at
cfg.secret.gate_<componentId>, a secret that does not exist yet - so it reports
WAIT secret cfg.secret.gate_main: not declared from the first apply, naming what to set. The
path it used is in the pre-migration body the configurer keeps under <config-dir>/backup/.
The credentials are a secret of kind bytes, fetched at apply and held in memory -
nothing writes them to disk, and no export, manifest or status carries them. The secret's
declaration has to admit gate.gate, which is the --consumer of
algol secret set <id> --kind bytes --consumer gate.gate; without it the value is not served and
the entry reports ERROR ... not admitted. Omitting remoteNatsCredsRef connects without
credentials, which only a remote that requires none accepts - any other answers with an
authorization violation, which gate reports on gate.remoteconn.<id> and retries with a backoff
rather than hammering.
Configuration status¶
The effective stage, as the gate module reports it. Everything below is a validation the module
performs on a config it received; the whole config is refused, so a rejected entry keeps forwarding
under its previous configuration until a valid one replaces it.
| Status | Message (example) | Meaning | Cleared by |
|---|---|---|---|
OK |
- | The entry is applied. With enabled = true this means the remote connection was created, not that it is up - see the application status below. |
- |
DISABLED |
- | enabled = false. The target is kept but forwards nothing and its buffer is discarded. |
enabling the entry |
ERROR |
remoteNatsUrl is required when enabled=true |
Nothing to connect to. | a remoteNatsUrl |
ERROR |
deviceId is required when enabled=true / tenantId is required when enabled=true |
Without both there is no cloud subject to publish on. Normally caught earlier, at bind time; this is the module refusing to build one out of empty segments whatever the config arrived through. | an identity, usually a binding to sys.deviceinfo.main |
ERROR |
invalid device identity: ... |
deviceId or tenantId is not a component-reference part. |
a corrected value |
ERROR |
invalid remoteNatsCredsRef: ... |
The reference does not name a secret component. |
a cfg.secret.<id> |
WAIT |
secret cfg.secret.<id>: not declared / : no value |
The credentials the entry names do not exist on this device. A connection built with an earlier value is dropped: an operator who deleted the secret said this credential must no longer be used. | declaring and setting the secret, which re-dispatches the config |
WAIT |
secret cfg.secret.<id>: unavailable |
The configurer did not answer. The live connection is kept - a restart is not a revocation. | the configurer answering |
ERROR |
secret cfg.secret.<id> is a string secret, needs bytes |
The reference names a secret of the wrong kind. | a secret of kind bytes |
ERROR |
secret cfg.secret.<id>: not admitted |
The declaration does not list gate.gate in its consumers, so the value is not served. |
adding the consumer to the declaration |
ERROR |
statusForwardInterval must be >= 10 seconds if set (got N) (likewise dataStatusForwardInterval) |
Below the floor. 0 is not "too low" - it means "leave this interval alone". |
a value of 0 or >= 10 |
ERROR |
invalid statusForwardFilter '<value>': ... (likewise dataStatusForwardFilter, eventForwardFilter) |
A filter is not a parseable wildcard component ref. | a corrected filter |
ERROR |
failed to connect remote NATS: ... |
Only a URL the client refuses outright (unparseable, unknown scheme). An unreachable or unauthenticated server is not this: the connection retries in the background and the entry reports OK, with the truth on the application status. |
a corrected remoteNatsUrl |
Never reached: an unresolved $deviceId / $tenantId. Properties are bound by the configurer, so
a config waiting on sys.deviceinfo.main is held there and never dispatched - that wait is WAIT
on the resolution component, not on gate.gate.<id>. The same goes for the field constraints the
type declares (required_if, the component-part pattern): those are enforced at bind time and
surface as a resolution ERROR.
Application status¶
No typed status is registered under this component name.
Two components, both published on the status tick (default every 10 s).
| Component | Status | Meaning |
|---|---|---|
gate.app.main |
OK |
At least one entry is enabled and has a remote connection object |
DISABLED |
No entry does - no entry configured, or every one of them disabled | |
gate.remoteconn.<id> |
OK |
The remote connection is CONNECTED and no publish is failing |
WAIT |
Connecting, reconnecting, or draining | |
ERROR |
Disconnected or closed, the remote is refusing this entry's publishes or refused its credentials, or it is connected while a publish has failed and none has been acknowledged since | |
DISABLED |
The entry is disabled, or enabled with no connection object |
gate.app.main is coarser than it looks and is not a cloud-reachability signal: the connection
object exists from the moment the entry applied, so OK here survives a link that has never come
up. The per-entry component is where connection health actually lives, and sinceLastConnected
(-1 until the first connect) is what distinguishes "never reached the cloud" from "reached it and
dropped". gate.app.main never reports ERROR or WAIT.
A refused publish is reported on gate.remoteconn.<id> as ERROR with a non-zero
authzErrors, and it outranks the connection status - the link is up and NATS says OK, but
nothing this entry sends is being accepted. This is the only signal that deviceId / tenantId
disagree with what remoteNatsCredsRef authorizes: a NATS credentials file carries no tenant or
device claim, so the mismatch cannot be caught when the config is applied. It is not the same as
authnErrors, which counts the credentials being refused outright - accepted-but-not-authorized
against not-accepted-at-all.
A remote that takes a publish and never acknowledges it is the third way an entry forwards
nothing, and the only one neither refusal counter sees: authzErrors and authnErrors both
stay 0, NATS reports the connection CONNECTED, and sinceLastConnected is 0. It reports
ERROR from the first failed publish until one succeeds, with msgsPublishErr climbing and
msgsBufferSize growing towards --remote-msgs-buffer-size, after which the oldest buffered
messages are overwritten and msgsDropped climbs too. One cause is that nothing on the remote
is bound to the subject this entry publishes under - an entry pointed at an account that does
not hold the stream, or a stream whose subject list misses the shape. The subject is
algol.<tenantId>.device.<deviceId>.<moduleId>.<context>.<componentName>.<componentId>: the
module id comes before the context, so a filter written as algol.*.device.*.status.> matches
nothing, and the statuses-only filter is algol.*.device.*.*.status.*.*.
authnErrors is the other refusal and means the opposite: the remote would not take the
credentials at all, so there was no identity for it to check. The two are authentication and
authorization, and sinceLastConnected tells them apart at a glance - it stays -1 for an
authnErrors entry, which never got on the bus, while an authzErrors entry is connected and
publishing where it may not. (NATS reports the authentication failure as an "authorization
violation", which is why the field names rather than the error strings are the thing to read.)
nats.go stops reconnecting after the same authentication error twice, which is what keeps a device
that will never be authorized from hammering the remote; gate then retries on its own, from 5 s
doubling to 5 minutes, re-reading the secret each time so a rotation is picked up. The entry
reports ERROR throughout.
status is the live signal and returns to OK on the next publish the remote accepts (or on a
corrected identity, which has not been refused yet). authzErrors is cumulative and does not
go back down, so match a trigger or a dashboard on the status, not on the counter. A disabled entry
reports DISABLED whatever it was refused before.
Example¶
Example: identity and credentials bound from deviceinfo¶
One config for a whole fleet: the identity binds from sys.deviceinfo.main, and the credentials
reference is composed from the same two properties, so each device reads its own secret. The
braces are required - _ is an identifier character, so gate_$tenantId_$deviceId would look up
a property named tenantId_ and fail. The gate_<tenant>_<device> spelling is a convention of
this example and nothing enforces it: any cfg.secret.<id> is accepted, and a device configured
by hand can name its secret whatever its operator likes.
On a device reporting parametric / rar4000 this dispatches
remoteNatsCredsRef = "cfg.secret.gate_parametric_rar4000", which is then declared and set with:
type = "resolution"
targetConfigType = "gate"
[[properties]]
type = "configRef"
name = "deviceId"
ref = "sys.deviceinfo.main"
jsonPath = "$.deviceId"
timeout = -1
[[properties]]
type = "configRef"
name = "tenantId"
ref = "sys.deviceinfo.main"
jsonPath = "$.tenantId"
timeout = -1
[targetConfig]
enabled = true
remoteNatsUrl = "tls://connect.ngs.global"
remoteNatsCredsRef = "cfg.secret.gate_${tenantId}_${deviceId}"
deviceId = "$deviceId"
tenantId = "$tenantId"
{
"type": "resolution",
"targetConfigType": "gate",
"properties": [
{
"type": "configRef",
"name": "deviceId",
"ref": "sys.deviceinfo.main",
"jsonPath": "$.deviceId",
"timeout": -1
},
{
"type": "configRef",
"name": "tenantId",
"ref": "sys.deviceinfo.main",
"jsonPath": "$.tenantId",
"timeout": -1
}
],
"targetConfig": {
"enabled": true,
"remoteNatsUrl": "tls://connect.ngs.global",
"remoteNatsCredsRef": "cfg.secret.gate_${tenantId}_${deviceId}",
"deviceId": "$deviceId",
"tenantId": "$tenantId"
}
}
gpsapp¶
Component ref: gps.gpsapp.main
Delete: singleton (RFC-023) - the component is reset to the module's startup default and keeps answering.
Singleton. Whether the gps module drives the GNSS receiver, the UART speed it talks to it at, and the navigation rate it programs into it. The speed and the rate are one receiver reconfiguration (CFG-PRT, then CFG-RATE) and constrain each other, which is why both are here. The device node stays the module's --gps-device flag: compose binds it into the container, so a config value could never make another one appear.
On delete the reader stops and the receiver keeps its own settings: algol never unconfigures a receiver.
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be gpsapp. |
enabled |
bool | no | Enabled says whether the module talks to the receiver at all. Disabled, no port is opened, no command is sent, no data is published, and chronyd gets no GPS time samples. |
baudRate |
int | when enabled | BaudRate is the UART speed the receiver is talked to at, in baud: 19200, 38400, 57600 or 115200. A receiver found at another speed is switched to it. It is bounded below by frequency, because the line has to carry the rate: up to 2 Hz needs 19200 or more, 5 and 10 Hz need 38400 or more, 25 Hz needs 115200. One of 19200, 38400, 57600, 115200. |
frequency |
number | when enabled | Frequency is the navigation rate the receiver reports at, in Hz: 0.5, 1, 2, 5, 10 or 25. Every data channel on the receiver's data source runs at this rate. One of 0.5, 1, 2, 5, 10, 25. |
The line carries UBX only: NAV-PVT once per epoch and NAV-SAT once a second, whatever the rate. The speed has to keep it at most half loaded, so a pair below the minimum is refused on submit:
frequency |
Minimum baudRate |
|---|---|
0.5, 1, 2 |
19200 |
5, 10 |
38400 |
25 |
115200 |
Whether the receiver itself accepts a rate is only known when the config is applied: a rate the receiver refuses is a config ERROR, not a validation error.
Consumer binding¶
A channel on gps.dsource.gps declares a configRef on this config. The configurer then holds the channel back until gps.gpsapp.main reports OK - until the receiver runs at the configured rate - so a channel is never built against a rate the receiver is not yet at. The property binds no field; it is the boot barrier only. A later rate change is applied by the module to every channel it drives, nothing is re-resolved.
type = "resolution"
targetConfigType = "channel"
[[properties]]
type = "configRef"
name = "gpsRate"
ref = "gps.gpsapp.main"
jsonPath = "$.frequency"
timeout = -1
[targetConfig]
enabled = true
description = "GPS position"
sourceRef = "gps.dsource.gps"
[[targetConfig.processors]]
type = "columnSelector"
channelType = "GeographicCoordinate"
[[targetConfig.processors.columns]]
inputColumnName = "lat"
name = "lat"
[[targetConfig.processors.columns]]
inputColumnName = "lon"
name = "lon"
[[targetConfig.processors.columns]]
inputColumnName = "alt"
name = "alt"
{
"type": "resolution",
"targetConfigType": "channel",
"properties": [
{
"type": "configRef",
"name": "gpsRate",
"ref": "gps.gpsapp.main",
"jsonPath": "$.frequency",
"timeout": -1
}
],
"targetConfig": {
"enabled": true,
"description": "GPS position",
"sourceRef": "gps.dsource.gps",
"processors": [
{
"type": "columnSelector",
"channelType": "GeographicCoordinate",
"columns": [
{ "inputColumnName": "lat", "name": "lat" },
{ "inputColumnName": "lon", "name": "lon" },
{ "inputColumnName": "alt", "name": "alt" }
]
}
]
}
}
Configuration status¶
The effective stage, as gps reports it for gps.gpsapp.main. One state drives it: the receiver is ready when its measured rate matches the configured one.
| Status | Message (example) | Meaning | Cleared by |
|---|---|---|---|
DISABLED |
- | enabled = false, or no config yet: no port open, no command sent, no data |
enabling |
WAIT |
configuring receiver to <f> Hz |
the speed and rate commands were sent, or are being retried, and the measured rate is not yet within tolerance | the measured rate converging |
ERROR |
receiver rejected <f> Hz |
the receiver answered the rate command with a NAK: this receiver does not do that rate | a config update with another rate |
ERROR |
channel '<ref>' cannot run at <f> Hz |
a channel on gps.dsource.gps cannot be built at that rate; nothing was changed, the receiver runs on as before |
a config update with a rate the channel can run at, or a changed channel |
OK |
- | the receiver reports at the configured rate | - |
A receiver that goes silent after OK re-enters the reconfiguration loop and the status returns to WAIT; a channel already dispatched is unaffected, since nothing is re-resolved after the first OK. A pair below the minimum speed never reaches the effective stage: it is refused in the write's own answer.
Application status¶
Typed status gpsapp is registered under this component name.
The receiver's health, independent of the config axis: fix state (status is OK on a 3D fix, WAIT on a 2D fix, ERROR otherwise), message recency, satellite counts, the last position, and frequency, the rate measured on the stream (0 until measured), which is what the receiver delivers against the configured one.
Example¶
gsm¶
Component ref: sys.gsm.<iface>
Delete: system twin (RFC-023) - algol's config is withdrawn; the OS resource it mirrors is left exactly as it is.
A cellular data connection (a NetworkManager gsm connection) over the modem the board controller powers.
Its ipv4 object is the shared IPv4 configuration, but only method and routeMetric say anything about a modem: the carrier addresses the data session, so method is auto (or disabled for an IPv6-only session) and any other value is refused, while addresses, routes and sharedDhcp are accepted by the shared fragment and mean nothing here - the web UI does not offer them. Omitting ipv4 entirely leaves the profile's IP settings alone, including a route metric set in the image.
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be gsm. |
enabled |
bool | no | Enabled brings the connection up; disabled, it is left down. |
apn |
string | no | APN is the access point name the carrier issues for the data session. |
mtu |
int | no | MTU is the interface MTU in bytes, 68 to 1500; 0 leaves NetworkManager's default. Between 68 and 1500. |
ipv4 |
object | no | IPv4 configures the data session's routing preference; omitted, the connection keeps whatever the NetworkManager profile holds. Only method and routeMetric apply to a modem: method is auto (the carrier assigns the address over DHCP) or disabled (an IPv6-only session), and routeMetric orders this uplink against the others. |
ipv4.method |
string | yes | How IPv4 is obtained. auto is a DHCP client, manual is static addresses, shared is a LAN (DHCP server, DNS relay, forwarding and masquerade), disabled turns IPv4 off. One of auto, manual, shared, disabled. |
ipv4.addresses |
string array | no | Static addresses (CIDR notation), the first of which is the interface's primary address. Compatible with auto, manual, shared (shared requires exactly one) and disabled (held while IPv4 is off). Gateways are not stored per-address in NetworkManager; use routes with 0.0.0.0/0 instead. At most 3 items. |
ipv4.routes |
array | no | Static routes, each a destination network and its gateway. shared forbids any. |
ipv4.routes[].network |
string | yes | Network is the destination in CIDR notation, 0.0.0.0/0 for the default route. Must be an IPv4 address in CIDR notation. |
ipv4.routes[].gateway |
string | yes | GatewayAddress is the IPv4 address of the next hop. Format: ipv4. |
ipv4.sharedDhcp |
string | no | Whether a shared LAN hands out addresses. server serves the hosts of the configured prefix, none serves nobody - what a segment that already has a DHCP server on it needs. An empty value is server. Only valid with method shared; the address, the DNS relay, the forwarding and the masquerade are the same under both values. One of server, none. |
ipv4.routeMetric |
int | no | The priority of this interface's routes, IPv4 and IPv6 alike. The lowest metric on the device wins, so a smaller number is a preferred uplink and this is what orders several of them - a cellular link ahead of a connected Ethernet socket, say. Unset leaves the choice to NetworkManager, which takes it from the device type (Ethernet 100, WiFi 600, cellular 700); setting it again is how a metric written into the profile elsewhere is overridden, and removing it returns the interface to that default. At least 1. |
An SMS sent with send_sms (Commands below) is reported by the sms_sent and sms_received events; reset hardware-resets the connection's modem, see Messaging.
See Applying configuration for a GSM example.
Commands¶
| Command | Request | Description |
|---|---|---|
sys.gsm.<id>.reset |
none | Resets the modem of this gsm connection. |
sys.gsm.<id>.send_sms |
JSON | Sends an SMS over the modem of this gsm connection. Refused with 429 while the send queue is full. |
Configuration status¶
The effective stage, as sys reports it. Same activation rows as bridge, with no
rows of its own: the modem's own troubles reach this axis only as the activation failure they
cause (ERROR, the connection cannot be activated/deactivated). Which trouble it was - no
SIM, a locked SIM, no modem at all - is modemFault on the application axis below, since none
of them is a statement about the configuration.
Application status¶
Typed status gsm is registered under this component name.
What the connection reports about the modem underneath it, published on
every status tick, and again the moment status moves
(RFC-029 D1). It also carries the interface counters of
ethernet, on the modem's own netdev - the figures a metered plan is billed on -
absent until the modem has registered and been given one.
status here is the same statement the config status above makes, on the axis a rule can act
on; the modem fields below are gsm's own and are independent of it.
modemFault (numeric) is why there is no usable modem. It is always reported, including while
the connection is down - which is exactly when a SIM problem shows, since such a connection can
never come up:
modemFault |
When |
|---|---|
0 none |
A modem with a readable, unlocked SIM. Whether the link is up is modemState, not this |
1 noModem |
No modem is there at all. The resting state until the board controller powers the modem up, so not by itself a fault |
2 simMissing |
The modem reports no SIM inserted |
3 simError |
A SIM is inserted but unreadable |
4 simPin |
The SIM is waiting for its PIN |
5 simPuk |
The SIM is locked after too many wrong PINs, which only its PUK clears |
6 networkLock |
The SIM is refused by the modem's carrier lock |
7 other |
The modem failed for a reason this vocabulary does not name, or could not be read at all. modemFailedReason carries the modem's own number |
On a device whose SIM tray is empty, sys.gsm.gsm0 reports config ERROR with modemFault = 2
and modemFailedReason = 2; with the SIM in but PIN-locked, modemFault = 4 and
modemState = 2. On the configuration axis the two are indistinguishable, and so is a wrong
APN.
A wrong APN is not a modem fault and is not reported as one. The SIM is readable and the modem
registers on the network; only the data session fails. It shows as modemFault = 0 none with
the modem registered (typically modemState = 8) and config ERROR - the radio is up and no
bearer came up, which narrows it to the APN, the subscription, or its credentials, but does not
separate those three. NetworkManager's own reason for the failure
(NM_DEVICE_STATE_REASON_GSM_APN_FAILED) is the reason for a device state change rather than
a state, and NM's next activation retry overwrites it, so neither axis carries it: naming the
APN as the cause means latching that reason off NM's Device.StateChanged signal.
modemState is the modem's own state, passed through untranslated (-1 failed, 0 unknown,
1 initializing, 2 locked, 3 disabled, 6 enabled, 7 searching, 8 registered,
10 connecting, 11 connected). Absent while no modem was found, or where the modem's state
could not be read - which is the other fault above.
modemFailedReason is the modem's untranslated reason for having failed (0 none, 1 unknown,
2 sim-missing, 3 sim-error, 4 unknown-capabilities, 5 esim-without-profiles), absent
while the modem has not failed. simMissing and simError are the two it names in modemFault;
it is worth reading when the fault is other.
imei is the modem hardware's own identifier, independent of the SIM, so it says which
physical modem the rest of the figures are about and makes a swapped modem visible. It is
absent where the modem reports none, which is the case for one that failed before it was fully
probed.
The rest: signalQuality (percent, 0 where the modem calls its own reading stale), bearers,
modemResetCounter / modemResetErrCounter, sms_sent_success / sms_sent_failure, and the
interface traffic counters, which appear once the modem has registered and been given an
interface. The reset and SMS counters are kept against the modem hardware, so they carry over
when a reset re-enumerates the modem.
Example¶
httpendpoint¶
Component ref: <module>.httpendpoint.<id>
Delete: regular (RFC-023) - the component is destroyed and disappears from config ls after commit.
An HTTP webhook endpoint: a URL on the device that a third-party system posts to, for
example an alarm panel on the site LAN that reports a door opening. The endpoint is served
by the module named in the ref, on that module's own HTTP server, so the module (and with it
the port) is chosen by the ref: webui.httpendpoint.doorpanel is served on webui's
listener. webui is the only module that hosts endpoints so far.
The URL is the host's prefix, the endpoint ref and an optional subpath:
The prefix comes from the host module's --http-endpoint-prefix flag (default /hooks);
an empty prefix means the module serves no endpoints. The subpath (/door/3) is passed on
to the event as path, so one endpoint can serve several senders or channels. A ref of
another module, an unknown ref and a disabled endpoint all answer 404.
Every accepted request is answered 202 Accepted at once and published as one
event2 http_request_received about the endpoint, carrying the
method, path, query, source address, content type and the body as base64 in payload
(always base64, whatever the body holds; an empty body is an empty payload). Delivery is
at most once: nothing is stored, and a request that arrives while the bus is unreachable is
answered 503. A rejected request (failed authentication, a body too large for one NATS
message) gets a 4xx and publishes nothing. mode is async because that is the only way a
request is answered so far.
A trigger reacts to the event. The rule for the door panel decodes the JSON body:
let p = event_val('webui.http_request_received.doorpanel.payload');
p != nil && fromJSON(fromBase64(p)).state == "open"
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be httpendpoint. |
enabled |
bool | no | Enabled serves the endpoint. A disabled endpoint answers 404 like an unknown one, and may leave mode and auth out. |
mode |
string | when enabled | How a request is answered: async answers 202 Accepted at once and publishes the request as an event. One of async. |
auth |
object | when enabled | How a caller proves it may post, as a union discriminated by type: none (every caller is accepted), bearer (a token held in a secret of kind string) or basic (a user name and a password held in a secret of kind bcrypt). One of none, bearer, basic, discriminated by type. |
Authentication¶
auth has no default: an endpoint open to every caller is written as none. The secrets
bearer and basic reference are declared with the endpoint's component group
(e.g. webui.httpendpoint) among their consumers.
none¶
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be none. |
Every caller is accepted. Use it only on a network where every host may trigger the rules that react to the endpoint.
bearer¶
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be bearer. |
secretRef |
component ref | yes | The token callers must send as "Authorization: Bearer <token>", as a reference to a secret of kind string ("cfg.secret.<id>"). |
The caller sends Authorization: Bearer <token>; the token is compared in constant time
against the value of a secret of kind string.
basic¶
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be basic. |
username |
string | yes | The user name callers must send. Must not contain a colon, which Basic authentication uses as the separator. |
secretRef |
component ref | yes | The password, as a reference to a secret of kind bcrypt ("cfg.secret.<id>") whose value is the password's bcrypt hash. |
The caller sends HTTP Basic credentials (RFC 7617). The password is checked against the
value of a secret of kind bcrypt, the same kind a webuser password uses.
Configuration status¶
| Status | Message (example) | Meaning | Cleared by |
|---|---|---|---|
DISABLED |
- | enabled = false; the path answers 404 |
enabling it |
OK |
- | the endpoint is routed and accepts requests | - |
WAIT |
secret cfg.secret.doorpanel_token: no value set |
the referenced secret has no value yet; requests are answered 503 until it arrives | setting the secret's value |
WAIT |
module serves no http endpoints |
the host module runs with an empty --http-endpoint-prefix |
restarting the module with a prefix |
ERROR |
secret cfg.secret.doorpanel_token is of kind bytes, want string |
the secret is not of the kind the auth variant needs (string for bearer, bcrypt for basic) |
a corrected update, or a secret of the right kind |
ERROR |
secret cfg.secret.doorpanel_token does not admit webui.httpendpoint |
the secret's declaration does not list this endpoint's component group among its consumers |
adding the group to the secret's consumers |
DELETED |
- | the config was deleted; the path answers 404 | writing it again |
Never reaches the effective stage: a missing mode or auth on an enabled config, a
mode other than async, an auth without its secretRef (or username for basic), a
secretRef that does not name a cfg.secret.<id> component, or a username containing a
colon are refused by validation in the configurer. A config addressed to a module that does
not host endpoints gets no answer at all.
Application status¶
Typed status httpendpoint is registered under this component name.
Health telemetry is published per endpoint under the endpoint's own ref: counters for
accepted requests and for each kind of refusal (failed authentication, rate limiting after
repeated failures, a body too large, the endpoint not ready, a failed publish), plus
the seconds since the last accepted request (-1 while there was none since process start).
Refused requests do not reset it, so a sender that went silent shows there. A request to
an unknown ref is the host server's 404 and is not counted on any endpoint.
No on-change emit: the event already reports each request.
Example¶
algol config update webui.httpendpoint.doorpanel < httpendpoint.toml
algol config commit webui.httpendpoint.doorpanel
kubepod¶
Component ref: sandbox.kubepod.<podId>
Delete: regular (RFC-023) - the component is destroyed and disappears from config ls after commit.
Retired module
The sandbox module that served this type is retired (2026-09-07): no image is built from it and nothing on a current device applies a kubepod config. The type stays registered so an existing config still loads and the name stays reserved.
Deploys a container pod. The containers array follows the Kubernetes pod container format.
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be kubepod. |
containers |
array | no | Containers are the pod's containers in the Kubernetes container format, each a free-form object: name, image (one in the sandbox image store), env, volumeMounts. |
Container fields (common):
| Field | Description |
|---|---|
name |
Container name |
image |
Image reference (must exist in the sandbox image store) |
env |
{ "name": "...", "value": "..." } entries |
volumeMounts |
{ "name": "...", "mountPath": "..." } entries |
Application status¶
Typed status kubepod is registered under this component name.
The status type stays registered with the retired module; nothing publishes it on a current device.
Example¶
algol config update sandbox.kubepod.workload < kubepod.toml
algol config commit sandbox.kubepod.workload
ociimage¶
Component ref: sandbox.ociimage.<id>
Read-only. Published by the module; not writable through the config store.
An image present in the sandbox container store. Published by the sandbox module, which is retired (2026-09-07), so nothing publishes it on a current device; not authored.
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be ociimage. |
id |
string | no | ID is the image id. |
tags |
string array | no | Tags are the image's tags. |
digest |
string | no | Digest is the image's content digest. |
Used as the source of a kubepod image reference. algol config ls sandbox.ociimage.* lists what is on the device.
Application status¶
No typed status is registered under this component name.
Nothing is published under this name.
led¶
Component ref: <module>.led.<id>
Delete: regular (RFC-023) - the component is destroyed and disappears from config ls after commit.
One host indicator, driven by one or more light elements.
An indicator is one thing an operator reads, and what it shows is its elements' combined appearance: a green element blinking behind a steady red one reads as alternating yellow and red. So the config declares the elements the board actually has, each named by the color it emits, and every state is expressed per element. yellow is a real element only where the board has a yellow die - on a green + red indicator, yellow is what {"green":"on","red":"on"} looks like.
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be led. |
name |
string | yes | Name is what this indicator is called on the device, ideally the label printed next to it on the case ("PWR", "REC"): what an operator standing at the box and a person reading a screen have to be able to say to each other. Never empty, and kept short - it is a label, not a description. At least 1 character. |
position |
int | no | Position is an indication where to position this LED in comparison to other LEDs visually. From left to right. |
description |
string | no | Description is free text for config ls and the web UI. Name is what the indicator is called; this is whatever else is worth saying about it. |
elements |
string array | yes | Elements are the light sources behind this indicator, each named by the color it emits. Most indicators have exactly one. Two elements of the same color would be indistinguishable once mixed, so a name appears at most once. Each entry is one of red, green, yellow. |
allowedPatterns |
string array | no | AllowedPatterns constrains how an element may be lit; empty means all patterns are allowed. Off is admitted regardless of the list - it is the resting state every element a set does not name falls back to, so listing it changes nothing. Each entry is one of off, on, blink. |
allowedStates |
array | no | AllowedStates enumerates the states this indicator is able to show; empty means every combination of its elements and patterns is allowed. Each entry is the indicator's complete state, exactly as initialState and a set body are: an element an entry does not name is off, so {"green": "on"} and {"green": "on", "red": "off"} are the same entry. The all-off state is always allowed and need not be listed. An entry may only name elements this indicator declares, and may only light one with a pattern allowedPatterns permits. Listing the same state twice means nothing extra. |
initialState |
object | no | InitialState is the state applied on config update, and it is the indicator's complete state: an element of Elements that it does not name is set off, exactly as for the NATS led-set endpoint. Only an absent or empty map means "preserve current state". A key not in Elements, or a state allowedStates does not admit, is rejected. An event is emitted when the state changes, equivalent to calling the endpoint: re-applying a config the indicator already reflects announces nothing. Values: string, one of off, on, blink. |
name is what an operator standing at the device and a person reading a screen say to each other, so it is the label on the case rather than a sentence about the indicator - description is where the sentence goes. It is required and kept short, since a surface drawing a row of lamps has room for only a few characters under each. It need not be unique - two indicators may carry the same label, and the ref is what distinguishes them.
position is where the indicator sits among the device's others, left to right across the case. The web UI's Indicators card orders its row by it; indicators sharing a position, and those that leave it unset (which is 0, ahead of every placed one), follow in component id order.
initialState is the indicator's complete state, exactly like a set (below): an element of elements it does not name starts off. A key that is not one of elements is refused.
allowedStates is for an indicator whose elements do not carry independent patterns. A bi-color LED behind one register with a single blink bit can show either element blinking, or both, but not one blinking behind the other lit steady - so it lists the states it has, and everything else is refused rather than reported as a state nobody can see. Each entry is a complete state like initialState, so {"green":"on"} and {"green":"on","red":"off"} are the same entry and either spelling of a set matches either spelling of an entry. The all-off state is always allowed and need not be listed, for the same reason off is always an allowed pattern: omitting every element produces it. An entry may only name declared elements and may only light one with a pattern allowedPatterns permits; both are refused at config update.
Setting the state¶
The payload is the indicator's complete state - an element it does not name is set off, not left alone - so {} turns the indicator dark. That is what makes a set idempotent and free of races: two rules driving one indicator would otherwise each have to know what the other wanted.
An element name that is not in elements, a lit pattern outside allowedPatterns, or a state outside allowedStates, answers 400 and changes nothing. off and the all-off state are never refused - they are what an omitted element and an empty payload get, so their explicit spelling cannot be stricter than leaving the element out. An accepted state is recorded and, when it differs from the state the indicator was already in, emitted as a led_state_changed event and published on the component's status context.
A set asking for the state the indicator already shows succeeds and announces nothing: there is no edge to report, and the periodic status collection carries the state either way. The same holds for a config update whose initialState the indicator already reflects - which is what every restart republishes.
Commands¶
| Command | Request | Description |
|---|---|---|
sys.led.<id>.set |
JSON | Sets this indicator: one pattern per element, keyed by element name; an element not named is set off. The element names and the patterns are the indicator's config's. |
Configuration status¶
The status sys publishes for the applied config - the effective status stage of algol config info sys.led.<id>. It answers whether the indicator can be driven as configured; what it is currently showing is the application status below, and neither axis is derived from the other.
| Status | Message (example) | Meaning | Cleared by |
|---|---|---|---|
OK |
- | Applied. The elements are declared and the indicator is driveable by set. |
- |
ERROR |
invalid initialState: element "<name>" is not one of [...] |
The initial state names an element the indicator does not declare. Nothing is applied and the previous config keeps driving. | a corrected config update |
ERROR |
invalid initialState: element "<name>": pattern <p> is not in allowedPatterns [...] |
The initial state asks for a lit pattern this config forbids (off is always admitted). |
a corrected config update, widening allowedPatterns or changing the state |
ERROR |
invalid initialState: state {...} is not in allowedStates [...] |
The initial state is not one this indicator can show. | a corrected config update, listing the state or changing it |
ERROR |
invalid allowedStates[<i>]: element "<name>" is not one of [...] |
An entry of the list names an element the indicator does not declare - usually a list written against another board. Checked before initialState, so this is reported even when that is wrong too. |
a corrected config update |
ERROR |
invalid allowedStates[<i>]: element "<name>": pattern <p> is not in allowedPatterns [...] |
An entry lights an element with a pattern this config forbids, so the state could never be asked for. | a corrected config update, widening allowedPatterns or changing the entry |
DELETED |
- | The config was deleted. The component disappears after commit and sys stops driving the indicator; it keeps whatever state was last written (RFC-023 regular delete - nothing resets it). |
- |
led has no enabled field, so DISABLED never appears. Field validation - a missing or empty name, an elements or allowedPatterns entry outside its closed set, an initialState or allowedStates value that is not a pattern - is refused by the configurer at algol config update: the command fails, nothing is published, and the indicator keeps the config it already had.
Application status¶
Typed status led is registered under this component name.
One field per element the config declares, named after the element and carrying the pattern it was last commanded:
| Field | Type | Description |
|---|---|---|
element name (green, red, yellow) |
int | Last commanded pattern (0 unknown, 1 off, 2 on, 3 blink) |
An element the indicator does not declare is absent, which is a different statement from "exists and is dark". A declared element that nothing has commanded yet reports 0 unknown.
The fields report what sys last commanded - by set, or by a config update carrying initialState. Nothing reads the indicator back off the board controller, so a command that never reached the hardware still shows here. An element whose pattern a new config no longer permits resets to unknown, and so does the whole indicator when a new allowedStates no longer admits the state it is in - the combination can be forbidden while each element's pattern on its own is still allowed.
Driving the hardware¶
sys records the state and publishes it; the register write is a trigger rule, so the encoding stays with the board rather than in the module. On a RAR the four indicators are one mask register each, where bit 0 is green lit, bit 1 red lit and bit 2 blink - and 4 (blink with nothing lit) is off, because the byte protocol cannot send a 0 payload. allowedStates is what keeps every commandable state encodable, so the rule is a straight encode with no case for a state the board cannot show:
# trigger.trigger.led1-mask
[match]
type = "streamExpr"
interval = 1
expr = """
let g = status_val("sys.led.1.green");
let r = status_val("sys.led.1.red");
let m = (g >= 2 ? 1 : 0) + (r >= 2 ? 2 : 0) + ((g == 3 || r == 3) ? 4 : 0);
capture("mask", m == 0 ? 4 : m);
true"""
[action]
type = "jsonCommand"
cmdRef = "bc.bcapp.reg.led_cmd_mask_1"
minWaitPeriod = 5
cmdPayload = { "@value" = "mask" }
A top-level {"@value": ...} payload is replaced by the capture's value, so the command body is a bare number, which the register endpoint parses. An indicator nothing has commanded reports 0 unknown for every element and encodes to dark. The rule matches a level and so re-fires on every evaluation - minWaitPeriod is load-bearing here and must never be -1; a change suppressed inside the window is re-applied on the next evaluation.
Example¶
type = "led"
allowedPatterns = ["off", "on", "blink"]
description = "Main operator indicator"
elements = ["green", "red"]
name = "OPER"
[[allowedStates]]
green = "on"
[[allowedStates]]
red = "on"
[[allowedStates]]
green = "on"
red = "on"
[[allowedStates]]
green = "blink"
[[allowedStates]]
red = "blink"
[[allowedStates]]
green = "blink"
red = "blink"
[initialState]
green = "off"
red = "off"
{
"type": "led",
"name": "OPER",
"description": "Main operator indicator",
"elements": ["green", "red"],
"allowedPatterns": ["off", "on", "blink"],
"allowedStates": [
{
"green": "on"
},
{
"red": "on"
},
{
"green": "on",
"red": "on"
},
{
"green": "blink"
},
{
"red": "blink"
},
{
"green": "blink",
"red": "blink"
}
],
"initialState": {
"green": "off",
"red": "off"
}
}
This is the constrained shape: six lit states plus dark, which is every state a one-register bi-color indicator has. An indicator whose elements do carry independent patterns - two registers, one per die - leaves allowedStates out and reaches all nine.
A single-element indicator is the common case and stays short - name, elements = ["green"], and a set payload of one key. Leave allowedPatterns and allowedStates out unless they are a genuine restriction: empty means everything is allowed, which is what listing it all says anyway.
netprobe¶
Component ref: sys.netprobe.<id>
Delete: regular (RFC-023) - the component is destroyed and disappears from config ls after commit.
One active connectivity probe (RFC-037): a periodic reachability attempt over one path, whose outcomes feed monotonic counters and a recent-outcome bitmask on the component's telemetry. The probe publishes no verdict - a trigger rule holds the threshold (for example "reset the modem when most attempts over half an hour failed") and derives it from the counters or the mask, keeping no state of its own.
The probe owns its clock: interval is part of this config, the component schedules itself,
and at most one attempt is in flight. A probe is one target over one path; distinguishing a
dead target from a dead path is done in the trigger, by combining two probes.
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be netprobe. |
enabled |
bool | no | Enabled switches the probe on. A disabled config may be an incomplete shell: probe, interval and timeout are required only when enabled. |
name |
string | yes | Name is what this probe is called, the path it watches as a person would say it ("LTE uplink", "Site router"). Never empty, and kept short - it is a label, not a description. At least 1 character. |
description |
string | no | Description is free text for config ls and the web UI. Name is what the probe is called; this is whatever else is worth saying about it. |
interface |
string | no | Interface is the kernel network device attempts start from ("wwan0", "eth0"): the probe leaves over that device, rather than over whichever one the routing table would pick for the target. Empty probes the routed path. |
probe |
object | when enabled | Probe is what one attempt does, as a probe-kind union discriminated by type; icmp is the one kind so far. One of icmp, discriminated by type. |
interval |
number | when enabled | Interval is the probe cadence in seconds. The 64-bit successMask in the status spans the 64 most recent attempts, so interval also sets the mask's time horizon (interval 30 covers 32 minutes). At least 1. |
timeout |
number | when enabled | Timeout is how long one attempt waits for an answer, in seconds, and must be smaller than interval so attempts never overlap. Must not exceed the value of Interval. Greater than 0. |
name is what the probe is called wherever one is shown - the path it watches rather than a sentence about it, which is what description is for. It is required, including on a disabled shell: a probe is named before it is switched on. It need not be unique - two probes may carry the same name, and the ref is what distinguishes them.
interface decides which path the probe actually measures. The device is multihomed, so
the default route is usually not the path under test: left empty, an attempt follows
whatever the routing table picks for the target, which on a device with both ethernet and
LTE leaves over ethernet and vouches for the wrong interface. Naming the device binds each
attempt to it. The name is the kernel's (wwan0, eth0), not a component ref, and it is
not gated on enabled - a disabled shell may already say which path it is for. A device
that is absent when an attempt runs is not a config error but an unprobeable path: the
attempt fails to launch, which the counters and status report. Binding has requirements
the container must meet - see execution environment below.
icmp¶
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be icmp. |
target |
string | yes | Target is what the echo request is sent to: an IPv4 address, or a host name that resolves to one. Format: ipv4 or hostname. |
ICMP attempts use an unprivileged ping socket. Where the environment does not permit one -
or the named interface is not there - every attempt fails as unlaunchable and the
component reports status = ERROR (below) rather than fabricating path evidence. Without
an interface, attempts go over the routed path - whatever interface the routing table
picks per attempt.
A target that is a name is resolved once per attempt rather than once when the config is
applied: a record can change under a long-lived probe, and a device that configures the
probe before the path is up would otherwise never get one. A name that fails to resolve is
a failed attempt like any other unreachable target, not a config error and not an
unlaunchable attempt - a path that is dark usually takes its resolver with it, and putting
status on ERROR for that would mean an outage. Resolution goes through the host
resolver and is not bound to interface: only the echo request is, so a bound probe
whose target is a name still asks whichever resolver the routed path reaches. Name a
literal address where that matters.
Execution environment¶
A probe measures the network namespace it runs in, and sys runs in a container. Four
requirements, which interact:
| Requirement | Needed for | Where it comes from |
|---|---|---|
net.ipv4.ping_group_range covering the process's group |
opening the ping socket - every attempt, bound or not | the netns the container uses: a compose sysctls entry with bridged networking, the host's setting with network_mode: host |
CAP_NET_RAW |
SO_BINDTODEVICE, so only a probe that names an interface |
a compose cap_add |
| the device and its routes present in the container's netns | binding to an interface, and reaching a target on a LAN behind one of the device's own interfaces |
network_mode: host |
| a resolver the container can reach | a target that is a name, not an address |
the container's /etc/resolv.conf - the host's under network_mode: host |
CAP_NET_RAW is not a substitute for the sysctl. The two answer different questions
and both must hold for a bound probe. The kernel gates the ping socket on the group range
alone - there is no capability check and no root bypass on that path, which is why
ping(8) falls back to a raw socket when the ping socket is refused. sys opens only a
ping socket, so a missing or empty range fails every attempt however privileged the
container is.
A probe that names an interface requires network_mode: host. With bridged
networking the container's namespace holds one veth and nothing else, so the device does
not exist there at all and the bind fails with ENODEV. The same applies to an unbound
probe whose target sits on a LAN behind one of the device's own interfaces: the route is
the host's and the container does not have it.
network_mode: host and the sysctls entry are mutually exclusive. A net.* sysctl
is per-namespace, so Docker refuses to set one for a container sharing the host's - the
entry must be removed or the container will not start. The group range then comes from the
host, where the kernel default is 1 0: an empty range admitting no group, under which
every attempt fails. Check it with sysctl net.ipv4.ping_group_range and set it host-side
(a /etc/sysctl.d/ drop-in in the BSP) if it is not already open. cap_add: NET_RAW stays
either way - host networking changes which namespace the capability is checked against,
not whether it is needed.
Configuration status¶
| Status | Message (example) | Meaning | Cleared by |
|---|---|---|---|
DISABLED |
- | enabled = false; nothing probes, counters sit at zero |
enabling it |
OK |
- | the probe is built and scheduled. Says nothing about the path - that is the telemetry's job | - |
ERROR |
icmp target "..." is not an IPv4 address |
the target is an address literal this kind cannot reach - an IPv6 one. Anything that is not a literal at all is taken as a name to resolve, so this is not how a name that does not resolve reports | a corrected update |
ERROR |
unsupported probe kind ... |
the probe kind is not one this build implements (a config authored for a newer build passes through the codec and is refused here) | a corrected update, or a firmware that knows the kind |
Never reaches the effective stage: a missing or empty name, a missing
probe/interval/timeout on an enabled config, a target that is neither an IPv4
address nor a well-formed host name, an interval below 1, or a timeout above
interval are refused by validation in the configurer.
Application status¶
Typed status netprobe is registered under this component name.
Health telemetry: status is the probe's own operability - OK
while attempts can be made (a dead but probeable path is OK), ERROR while they cannot
even be launched (socket refused, the configured interface not bindable), DISABLED for
a disabled config. The evidence fields are probeCounter / successCounter / timeoutCounter
(monotonic; refused-or-unlaunchable failures are the remainder), rttUsCounter (cumulative
round-trip microseconds of successes - average RTT over a span is its delta over
successCounter's), interval (echoed), successMask (the 64 most recent outcomes as
fixed-width hex, newest in the lowest bit, 1 = success; decode in a trigger with
hex_int), and sinceTimestamp (when the current
unbroken run of identical outcomes began, Unix seconds - with the newest mask bit it reads
"failing since" or "healthy since", so proc_timestamp().Unix() - sinceTimestamp > 600
is a stateless "held for 10 minutes"). Counters restart at zero when the probe is
(re)configured. See telemetry-fields.
Example¶
A trigger resetting the modem when more than 70% of the mask's span (64 x 30 s = 32 min) failed, at most once per span:
{
"type": "trigger",
"enabled": true,
"name": "Reset lte0 on sustained packet loss",
"match": {
"type": "streamExpr",
"expr": "sum(map(0..63, 1 - bitand(bitushr(hex_int(status_val('sys.netprobe.lte0.successMask') ?? '0'), #), 1))) > 44 && status_val('sys.netprobe.lte0.probeCounter') >= 64"
},
"action": {
"type": "command",
"cmdRef": "sys.gsm.lte0.reset",
"minWaitPeriod": 1920
}
}
The probeCounter >= 64 guard keeps a freshly started probe's zero-filled mask bits from
counting as failures until the mask is full; minWaitPeriod at the mask's span keeps the
level match from re-firing while the mask still holds the failures that justified the reset.
loradevice¶
Component ref: chrp.loradevice.<devEUI>
Delete: regular (RFC-023) - the component is destroyed and disappears from config ls after commit.
One LoRaWAN end-device registered in ChirpStack under algol's tenant.
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be loradevice. |
name |
string | when enabled | Name is the device's human-readable name in ChirpStack. At least 1 character. |
deviceProfile |
string | when enabled | DeviceProfile is the id of the ChirpStack device profile the device is created with. At least 1 character. |
applicationKey |
string | when enabled | ApplicationKey is the root key (AppKey) the device joins with over OTAA. At least 1 character. |
tags |
object | no | Tags are string key/value pairs attached to the device in ChirpStack; a key is a component ref part. Values: string. Keys match ^[a-zA-Z_][0-9a-zA-Z_]*$. |
enabled |
bool | no | Enabled registers the device. A disabled config may be an incomplete shell: name, deviceProfile and applicationKey are required only when enabled. |
Application status¶
No typed status is registered under this component name.
Nothing is published under this name. A device's frame-counter validation totals are published under the lorastats component name, one component per device seen since the module started.
Example¶
algol config update chrp.loradevice.0102030405060708 < loradevice.toml
algol config commit chrp.loradevice.0102030405060708
loragateway¶
Component ref: chrp.loragateway.<gatewayId>
Delete: regular (RFC-023) - the component is destroyed and disappears from config ls after commit.
One LoRaWAN gateway registered in ChirpStack under algol's tenant.
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be loragateway. |
name |
string | yes | Name is the gateway's human-readable name in ChirpStack. At least 1 character. |
Application status¶
No typed status is registered under this component name.
Nothing is published under this name.
Example¶
algol config update chrp.loragateway.aabbccddaabbccdd < loragateway.toml
algol config commit chrp.loragateway.aabbccddaabbccdd
ntp¶
Component ref: chrony.ntp.main
Delete: regular (RFC-023) - the component is destroyed and disappears from config ls after commit.
The NTP servers and pools chronyd synchronizes the clock from.
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be ntp. |
servers |
array | no | Servers are the individual NTP servers chronyd polls. |
servers[].server |
string | yes | Server is the server's host name or IP address. Format: hostname, ipv4 or ipv6. |
pools |
array | no | Pools are the NTP pools chronyd draws servers from. |
pools[].pool |
string | yes | Pool is the pool's host name. Format: hostname. |
Application status¶
No typed status is registered under this component name.
Time-sync state is published on chrony.chronyapp.main (chronyapp): the health of the system clock, of the NTP-disciplined clock and of the PPS reference, with their readings.
Example¶
ntppps¶
Component ref: chrony.ntppps.main
Read-only. Published by the module; not writable through the config store.
Published by chronyd from its startup flags. Reports the PPS device the module disciplines the system clock from, and how NMEA is consumed. See Hardware Integration.
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be ntppps. |
device |
string | yes | Device is the PPS device path (/dev/pps0). Must be a valid filesystem path. |
nmeaDriver |
string | no | NMEADriver is how NMEA sentences reach chronyd: shm (shared memory) or sock (a unix socket). One of shm, sock. |
nmeaSockPath |
string | when nmeaDriver is sock |
NMEASockPath is the absolute path of the NMEA socket, required when nmeaDriver is sock. |
Application status¶
No typed status is registered under this component name.
The PPS reference's health is part of chrony.chronyapp.main, see ntp.
props¶
Component ref: cfg.props.<id>
Delete: regular (RFC-023) - the component is destroyed and disappears from config ls after commit.
A standalone, writable bag of configuration properties. Resolved values are mirrored to the read-only cfg.propsinfo.<id> config.
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be props. |
properties |
array | no | ConfigurationProperties is the bag: the properties by name, each a literal or a reference into another config. Each entry is one of string, int, float, json, configRef, error, discriminated by type. |
Application status¶
No typed status is registered under this component name.
A bag has no runtime; the configurer's health is on cfg.app.main.
Example¶
See the shared properties example.
propsinfo¶
Component ref: cfg.propsinfo.<id>
Read-only. Published by the module; not writable through the config store.
The resolved property bag of props, keyed by property name. Each value is the JSON the property resolved to. Other configs bind this with a configRef and a JSONPath selector (e.g. $.phone_number).
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be propsinfo. |
<key> |
any | no | Resolved property values, keyed by property name. Each value is the JSON the property resolved to, of whatever type that property holds. |
Delete the props config and this mirror goes with it.
Application status¶
No typed status is registered under this component name.
A mirror has no runtime.
resolution¶
Component ref: cfg.resolution.<id>
Delete: redirect (RFC-023) - a delete addressed here is forwarded to the target config.
The configurer also addresses the envelope under the target ref. It is the envelope that binds a property bag into another writable config's fields before the bound body is dispatched to the owning module. See Shared: configuration properties.
A submission may omit targetRef / targetConfigType - the configurer fills them from the addressed component. The stored shape, which the table describes, requires both.
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be resolution. |
targetSchemaVersion |
int | no | The schema generation of targetConfigType, stamped on the wire only (RFC-009). |
targetRef |
component ref | yes | TargetRef is the writable component this intent addresses. Derived from the resolution component id / addressed ref on ingest; required on the stored envelope for display and integrity checks. |
targetConfigType |
string | yes | TargetConfigType names the bound config type targetConfig materializes into. It must be a registered, non-resolution config type with a materializer. |
targetConfig |
any | yes | TargetConfig holds the target-shaped fields (nested under "targetConfig" on the wire). It may contain $name / @name tokens in strings and { "$value": "name" } slots on typed fields. |
properties |
array | no | Properties is the resolution property bag (may include configRef). For a props target it is also the config's definition: props has no fields of its own, so the resolved bag is copied onto the bound config and TargetConfig stays empty. Each entry is one of string, int, float, json, configRef, error, discriminated by type. |
props is the one target whose bag is its config: targetConfig must be empty and the resolved bag is copied onto the bound props body.
A property-free envelope is reduced to the bare target body when written to disk (config commit) and when the CLI shows the intent stages. The published and dispatched form stays the envelope.
Application status¶
No typed status is registered under this component name.
The envelope has no runtime of its own; its resolution state is the intent stage of the target's config info.
Example¶
A usbexport profile bound to a mount slot is the usual shape - see that type's example. A property-free update is just the target type:
The configurer wraps that as a resolution envelope on ingest.
recstorage¶
Component ref: recorder.recstorage.main
Delete: singleton (RFC-023) - the component is reset to the module's startup default and keeps answering.
Singleton. The recording medium: where Recordings are written, how the medium is identified, and the delete-oldest byte budget. Export profiles (s3upload, usbexport) cannot run while this config is disabled or the medium is not writable.
On delete the recording path is quiesced. Recordings already on the medium are not removed.
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be recstorage. |
enabled |
bool | no | Enabled admits writes to the recording medium. Disabled, nothing is written and nothing is purged; a catalog from an earlier apply may still be readable. |
recordingDir |
string | no | RecordingDir is the recording root (static after apply). Journal lives at <RecordingDir>/.journal. |
expectedMediumId |
string | no | ExpectedMediumId is compared by plain equality to the marker storageId. |
adoptUnmarked |
bool | no | AdoptUnmarked, when true, stamps an unmarked writable medium. Default false: unmarked media stay unclaimed until provisioned. |
maxRecordingBytes |
int | no | MaxRecordingBytes overrides the CLI delete-oldest bound when > 0 (bytes of catalogued Recordings). Ignored when MaxRecordingUsage is > 0. |
maxRecordingUsage |
int | no | MaxRecordingUsage is a relative delete-oldest bound as a percent of the filesystem capacity backing recordingDir (1-100): catalogued Recordings may occupy at most this share. When > 0 it takes priority over MaxRecordingBytes and the CLI flag. 0 means unset. The upper bound is enforced, not decorative: EffectiveMaxRecordingBytes gates on "usage > 0", so a value above 100 resolves to a budget larger than the medium and delete-oldest never purges - the disk fills instead. A negative value is already inert (it reads as unset), and is refused for the same reason it is meaningless. Between 0 and 100. |
Retention is delete-oldest only. It is a medium write, so it goes through the same write gate as recording: never purge while recstorage is disabled, the medium is unrecognized, the writing latch is set, or a reconfiguration is in flight.
Commands¶
| Command | Request | Description |
|---|---|---|
recorder.recstorage.<id>.recording_get |
JSON | One catalogued Recording by its own ref - the ref column of a recording_list page - with its measurement, channel, size, digest and context. |
recorder.recstorage.<id>.recording_list |
JSON | Lists one page of the recorder's catalog: the Recordings a measurement and a channel filter match, in path order, with the page's place in the whole set - the effective offset and the total the filter matches. |
Configuration status¶
The effective stage, as the recorder reports it for recorder.recstorage.main.
| Status | Message (example) | Meaning | Cleared by |
|---|---|---|---|
DISABLED |
- | enabled = false. No writes, no retention. Catalog may still be readable from a previous apply |
enabling |
WAIT |
medium absent / not mounted / path missing | recordingDir is not a usable filesystem yet (allowlisted roots may still be unmounted) |
the medium appearing |
WAIT |
medium identity mismatch | the marker does not equal expectedMediumId |
inserting the expected medium, or correcting the field |
WAIT |
unmarked medium | a writable medium with no marker, and adoptUnmarked is false |
provisioning the medium, or setting adoptUnmarked |
WAIT |
activating / catalog rebuild | an apply is in flight; size bounds and recordingDir have not settled yet |
activation finishing |
ERROR |
writing stopped | a journal or Parquet write failed; the writing latch is set and further writes are refused | a corrected medium / restart after repair |
OK |
- | the medium is adopted and writes are admitted | - |
Application status¶
Typed status recstorage is registered under this component name.
Health and storage stats: recordingStatus, recordingsCataloged, recordingBytes, recordingUsage, capacityBytes, capacityUsage, deletions, completion backlog.
Example¶
algol config update recorder.recstorage.main < recstorage.toml
algol config commit recorder.recstorage.main
userkeyinfo¶
Component ref: nats.userkeyinfo.<key>
Read-only. Published by the module; not writable through the config store.
One per person's key the bus admits, published by the nats app and republished
when that set changes and at every start
(RFC-035 D4).
The component id is the person's public NKey, because the key is the identity - a label is
decoration, and two laptops are two keys.
It exists because the two enforcement points know different halves: the nats app maps a key
to a role at connect, and the configurer, which sees the requester's key in the reply prefix,
needs the same mapping to decide a config write. It publishes nothing secret: it is the table
algol nats ls already prints.
Its counterpart is modulekeyinfo, and the pair is two types rather than
one on purpose: which of them answers for a key is what decides whether the writer is
role-decided at all, so a person's key cannot be mistaken for a module acting as itself.
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be userkeyinfo. |
key |
string | yes | The person's public NKey (U...), the same value as the component id. It is repeated in the body so a record read on its own carries the identity it describes. |
label |
string | no | Human-readable name for the key, from the enrolment or the web user's ref. It names the person in an audit line and is decoration everywhere else. |
role |
string | yes | The role the key holds, by name: a built-in one (keymaster, superuser) or a configured cfg.role.<name>. The rights are the role's, never this record's. |
source |
string | yes | Where the key came from: an enrolment in the registry, a web user's key, or the boot's root. One of registry, webuser, root. |
enrolledBy |
string | no | The key that enrolled this one, when a registry enrolment did. Empty for the boot's root and for a web user, neither of which is enrolled by anyone. |
enrolled |
timestamp | no | When the key became admissible. Zero for the boot's root, which is minted at start. |
Application status¶
No typed status is registered under this component name.
A key record has no runtime; the nats app's health is on nats.app.main.
algol config ls 'nats.userkeyinfo.*' # every key the bus admits: label, role, source
algol config info nats.userkeyinfo.UQRS...
There is no write path and no delete: an entry is withdrawn when its key stops being
admissible, which algol nats revoke, a disabled web user or the next boot does. Staleness is
bounded by the bus disconnecting a key whose entry changed, so a revoked key cannot be on the
bus behind a stale record.
modulekeyinfo¶
Component ref: nats.modulekeyinfo.<key>
Read-only. Published by the module; not writable through the config store.
One per module's key the bus admits, published by the nats app and republished
when a binding changes and at every start
(RFC-035 D14).
The component id is the module's public NKey, as it is for userkeyinfo.
It is userkeyinfo's other half. A config write carries a signature and its requester is the key
that signed (RFC-035 D14), and a module signs its own writes too - the trigger patches a
config, the web UI writes one - so the configurer needs a module's key to resolve to the
module. Without it the key would be one the configurer has never heard of, which it refuses
rather than guesses at, and the refusal would stop the trigger.
It carries no role. A module acting as itself is not decided at the configurer: its rights are the bus's, in the template the callout minted for it. What this record settles is that the key is a module the bus admits, and which one - enough to attribute a write, and enough not to refuse it.
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be modulekeyinfo. |
key |
string | yes | The module's public NKey (U...), the same value as the component id. It is repeated in the body so a record read on its own carries the identity it describes. |
name |
string | yes | The module id the key is bound to (sys, trigger, webui, ...), which is the name the module connects and is addressed under. |
bound |
timestamp | no | When the name was bound to this key - the module's first connect, or a person reopening the name for a stated key (RFC-034 D5). |
boundBy |
string | no | The key that bound the name, when an enrolment did. Empty when the binding is the module's own first connect, which nobody authorises. |
Application status¶
No typed status is registered under this component name.
A key record has no runtime; the nats app's health is on nats.app.main.
algol config ls 'nats.modulekeyinfo.*' # every module key the bus admits
algol config info nats.modulekeyinfo.UMNO...
There is no write path and no delete: an entry is withdrawn when the binding goes, which
algol nats revoke --module decides. A name whose binding was revoked stops being published,
and the bus disconnects the key, so a revoked module cannot be on the bus behind a stale
record.
role¶
Component ref: cfg.role.<name>
Delete: regular (RFC-023) - the component is destroyed and disappears from config ls after commit.
A configured role (RFC-035 D2): what a kind of person holding it may manage, write and patch, and what their views show (read is a view filter, RFC-035 D5), as four lists of component filters. The component id is the role name; the built-in names keymaster and superuser are reserved and refused. A filter is a component reference in which any segment may be the whole-token wildcard *, and trailing segments may be left off - an unset segment is unconstrained, so sys.* and sys are the same filter as sys.*.* (sys.ethernet.* is every ethernet config, ones created later included; *.*.* is everything). The four config sets nest - manage implies write implies patch implies read, and a role always reads its own definition - so nothing needs repeating across them. What separates the top two is whether the component's existence is the role's to decide: write covers the body of a config that is already there, manage adds creating one and deleting one. A fifth set, exec, is a second axis rather than a rung: it names the commands the role may send, independently of what it may configure - a role may operate a component it cannot write, and writing one grants no command on it. Managing a component does carry every command on it, since deciding that a thing exists is deciding what it does.
Manage, write and patch entries naming a privileged type are refused: role and webuser (self-widening) in all three, plus systemdscript, systemdapp (root on the host) and props (a bag has no fields of its own, so writing one is authoring it) for write and manage - manage reads the write table, being the wider of the two, so nothing it refuses can be reached by creating an instance instead of editing one - a bag is granted with patch instead, which is how a role sets the values a built-in role exposed. trigger is grantable: a trigger's actions run as an unconfined module, so the configurer decides what each action addresses (cmdRef, configRef, and a composite's members) against the write set when the config is submitted, and refuses a trigger that invokes another trigger's action (actionRef). An entry in those sets that leaves the component name unconstrained (sys.*.*, sys.*, sys) is refused for the same reason - it matches every type, the privileged ones included. Read entries are never privileged - seeing a definition widens nothing.
An enrolled key holding a configured role is admitted with the bus template derived from manage and exec (RFC-035 D7). The write set adds no permission of its own, which reads as a surprise and is the design working: a config write travels one subject with the ref in the body, and the configurer decides it per ref, per request. What the two write-side sets contribute to the bus is commands, and only manage does - exec names the rest. A web user additionally gets set on their own password secret, which is theirs by being them rather than by their role, so it is never written into a definition. The commands no configured role may send whatever its sets say - a secret's get, the registry verbs, and the ones whose target is in the request body - are denied on every derived connection. Editing, disabling or deleting a definition disconnects every connection admitted under it: a permission set is fixed at connect, so the holders are dropped and reconnect on the new definition - or are refused by it. Republishing an unaltered definition is not an edit and ends nobody's session.
sessionTimeout is the one field here that is not a permission: it is how long a web user holding the role may leave the browser alone before the web UI signs them out, measured from their last request and started again by every page, action and stream their browser asks for. It is the one clock on a session: a session in use stays open for as long as it is used, and otherwise ends at a sign-out, when the person's own webuser config or password changes, when an administrator signs everyone out of a full device, and at a restart; the web UI's profile page counts the span down. A device where nobody is signed out for being idle is sessionTimeout = -1 on every role and --session-idle-timeout 0; the built-in roles keymaster and superuser have no definition to write it in, so they take that flag's value (10 minutes by default). An edited definition reaches the sessions its holders already have, since the edit disconnects them and the web UI reads the new definition when it re-admits them.
At the configurer, config update, config delete and config patch are decided against the sets: a ref outside them is refused 403 naming the ref and the set, and nothing is written, so config info on the target shows that nothing happened. Which set a config update is decided against depends on what it does to the ref: changing a config that is already there asks write, while creating one - an update addressed to a ref the store holds no config for - and deleting one both ask manage. A create refusal says so (flower.flow.never does not exist and is not in the manage set of role fieldtech), because a role whose write set plainly covers the ref would otherwise be told only that a set it never listed was missing. A ref whose delete is pending counts as not there, so writing it again in that window is a create; config revert is the way back to the config that was on it. config commit and config revert are addressed by a filter, so they act on what the role reaches and report the rest as skipped rather than failing - commit asking manage for an entry whose pending operation is a delete, since committing that is what removes the startup file, and revert asking only patch whatever the entry carries, since restoring the committed state invents nothing. A bundle-addressed commit, revert or teardown (config delete --bundle) names a definite set and is refused whole unless every member is in manage. config ls and config info are intersected with read: a component outside it is simply not in the reply, with nothing said about how many were removed, and every record that survives carries what the role may do with it. The requester is the key in the reply prefix, looked up in userkeyinfo; a key that table does not name is refused, which is also what an operator sees in the seconds after a boot before the nats app has published it - the retry succeeds. keymaster and superuser are unrestricted here, and a module writing as itself is not decided by a role at all.
The bundle and manifest write verbs are refused whole, because a bundle is one thing with members and half of it is not a smaller one. The bundle and manifest write verbs ask for manage on every ref they cover: a document-addressed verb does more than one thing to those refs - an activation places members and deletes what its stale-in-bundle sweep reaches - so it asks the top grant once rather than working a narrower right out per member. bundle update and bundle delete are covered by what the FILE describes - the resulting member set for an update, the installed one for a delete, so a file naming refs outside manage is neither authored nor removed; a bundle that does not read back is refused to a configured role outright, its refs being unknowable. bundle activate and manifest import are covered by what they WRITE, which is more than the document names: activating a bundle also withdraws the bundle members it does not carry, and that sweep has to be in manage too. manifest check answers exactly what its import would, refusal included. manifest import --replace is refused to a configured role whatever it names, because a restore converges the whole device and withdraws everything the manifest does not describe - including what the role was never shown. manifest export is the one verb that filters instead of refusing, and only as a backup: it carries the startup configs the same role's import would accept - manage on the ref, no property declared or bound, a trigger acting only inside write and exec - and the bundles whose every member it would accept, and names the rest in an X-Algol-Manifest-Omitted reply header, which algol manifest export prints on stderr so it cannot land inside a redirected backup. A rollout export (--bundle) is refused instead, naming the bundle. config commit and config revert name the refs they did not act on and report the write as partial rather than successful. config ls prints a one-letter access column beside the ref and config info an Access row spelling out the grant, the verbs it opens and the role; a config info on a ref outside read reports "not found, or not in the read set of role X", since the intersection is silent and the two cannot be told apart. A web user's key is another.
On delete the role ceases to exist; live connections holding its name are dropped and identities holding it are refused from then on, until a keymaster defines it again. Fail closed, and never a lockout: keymaster is not a config.
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be role. |
enabled |
bool | no | Enable the role. A disabled role takes its holders out of service without deleting the definition: connections holding it are refused. |
description |
string | no | Free text for config ls and the web UI. |
read |
string array | when enabled | The components the role is shown: component filters "moduleId.componentName.componentId", where any segment may be the whole-token wildcard * and trailing segments may be left off (e.g. "sys.ethernet." is every ethernet config, ones created later included; "sys." and "sys" say the same as "sys.."; "..*" is everything). This scopes what a client shows the role - the config listing is intersected with it, and the status, telemetry and data views are filtered by it - so a component outside it is simply absent rather than refused. It is not a confidentiality boundary: an authenticated identity may read what the message bus carries, and a secret's value travels no readable subject in the first place. On the write path it does bind: a reference in a written config needs its target here. Required when the role is enabled: a role that is shown nothing is not a role. Patch, write, manage and exec entries imply read, and a role is always shown its own definition; neither needs repeating here. At least 1 item. |
patch |
string array | no | The components the role may patch but not write: toggle enabled and set the values of properties the config already declares, plus commit and revert of the ref - the right to operate what a built-in role authored without reconfiguring it. Same filter form as read, except that the component name must be given: an entry matching the role or webuser types is refused, and so is one that leaves the name unconstrained. |
write |
string array | no | The components the role may write: updating the body of a config that already exists, patch, commit and revert. Never creating a component and never deleting one - those are manage - and never a command on it, which is exec: authoring what a modem is configured to do and resetting it are separate grants. Same filter form as read, except that the component name must be given: an entry matching a privileged type (role, webuser, systemdscript, systemdapp, props) is refused, and so is one that leaves the name unconstrained. |
manage |
string array | no | The components the role may manage: everything write grants, plus the two operations that decide whether a component exists at all - creating one, and deleting one. They are one grant because they are one right: a delete followed by a create rewrites a config wholesale. It also carries every command on the component, so a component listed here needs no exec entry: deciding that a component exists is deciding what it does. Same filter form and the same privileged-type refusal as write. |
exec |
string array | no | The commands the role may send, as attribute filters "moduleId.componentName.componentId.commandName" - the filter form of the other sets with a fourth segment for the command, where any segment may be the whole-token wildcard * (e.g. "sys.gsm.gsm0.reset" is one command on one modem, "recorder.usbexport.disk01." every command on that export profile, and "sys.netprobe..start" one command on every probe). It is the right to operate a component without reconfiguring it, and it is independent of the other four: a role may hold it on a component it cannot write, and writing a component grants no command on it. A component named here is shown to the role, so nothing needs repeating in read. An entry naming a command no configured role may send is refused: a secret's value ("cfg.secret.<id>.get"), the identity registry's verbs ("nats.auth.main.<verb>"), and the commands whose target is named in the request body rather than by the component addressed - "sys.systemd.ctl.restart", "bc.main.execute.<cmd|cmds>", "sandbox.app.main.prune_images", "retx.app.main.dump" and "audio.player.main.play". An entry that leaves the command unconstrained is accepted and reaches every command on the component except those. |
sessionTimeout |
number | no | How long, in seconds, a person holding this role may leave the web UI alone before it signs them out; anything they do there starts the span again. 0 takes the web UI's own default, and -1 never signs them out for being idle. It is the one clock on a session: one in use stays open for as long as it is used, and otherwise ends at a sign-out, at a restart, and when the person's own configuration or this role is disabled or deleted. Seconds, positive, 0 for the default, or -1 to never time out. |
Configuration status¶
The effective stage, reported by the configurer's own handler:
| Status | Message (example) | Meaning | Cleared by |
|---|---|---|---|
OK |
- | The definition is applied | - |
DISABLED |
- | The definition is applied with enabled = false |
enabling it |
WAIT |
patch[0] cfg.props.site feeds sys.wlan.wlan0, not in the read set |
The role may change a property bag whose values feed a config it cannot read, so its holders would edit blind - the error a bad value produces lands on the fed config's status, where they never look (RFC-035 D9). Every set that can change a bag is checked - patch, write and manage - and the message names the one that matched |
widening read to reach the fed config, or dropping the bag from that set |
ERROR |
manage entry "...": <type> is a privileged type - <why> (or write entry, or patch entry) |
A property-bound set entry resolved to a privileged type - the ingest refusal cannot see a bag value, so it is caught after resolution | correcting the entry or the property it binds |
Never reaching the effective stage: a built-in name (keymaster, superuser) as the component id, a literal manage, write or patch entry matching a privileged type or leaving the component name unconstrained, an enabled role with an empty read set, and a malformed filter entry.
WAIT here is a report, not a refusal: the definition is applied and stays in service, every entry decides as written, and the status only says the keymaster has more granting to do - which is why the patch set can be authored before the read set is widened to match. It is evaluated when the role is applied, so a binding added afterwards does not move a settled role's status until the role is written again.
Application status¶
No typed status is registered under this component name.
A role definition has no runtime.
Example¶
type = "role"
description = "Site technician: edits LAN, GSM and remote access, toggles the LED rule"
enabled = true
manage = ["sys.netprobe.*"]
patch = ["trigger.trigger.gsm_led"]
read = ["sys.*.*", "secomea.*.*"]
sessionTimeout = 1800
write = ["sys.gsm.gsm0", "sys.ethernet.*", "secomea.secomea.main"]
{
"type": "role",
"enabled": true,
"description": "Site technician: edits LAN, GSM and remote access, toggles the LED rule",
"read": ["sys.*.*", "secomea.*.*"],
"patch": ["trigger.trigger.gsm_led"],
"write": ["sys.gsm.gsm0", "sys.ethernet.*", "secomea.secomea.main"],
"manage": ["sys.netprobe.*"],
"sessionTimeout": 1800
}
s3upload¶
Component ref: recorder.s3upload.<id>
Delete: regular (RFC-023) - the component is destroyed and disappears from config ls after commit.
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be s3upload. |
enabled |
bool | no | Enabled lets the profile run. Disabled, the config is stored, no worker runs and nothing is uploaded. |
autoStart |
bool | no | AutoStart makes the profile start a run on its own wake-up - for s3upload, the completion Notify(). With AutoStart false the profile runs only between a start and a stop command (local.recorder.cmd.s3upload.<id>.start|stop), which is what makes a policy like "upload on WiFi only" a trigger rule rather than a config write per link flap. See RFC-024. |
keyPrefix |
string | no | S3 key prefix to upload files under. A literal path, optionally referencing configRef properties (e.g. "${deviceId}") resolved once at config-bind time - see the "deviceId" example in the s3upload docs, sourced from sys.deviceinfo.main. Must be valid according to S3 path restrictions. Must be a valid filesystem path. |
bucket |
string | when enabled | Bucket is the S3 bucket the Recordings are uploaded into. |
endpoint |
string | no | Endpoint is a custom S3-compatible endpoint, a host name or an IP address; omitted for AWS itself. Format: hostname, ipv4 or ipv6. |
region |
string | no | Region is the AWS region of the bucket. |
credentials |
object | no | The static AWS credentials the uploads are signed with. Deprecated: use credentialsRef instead, and set at most one of the two. |
credentials.accessKeyId |
string | no | AccessKeyID is the AWS access key ID. |
credentials.secretAccessKey |
string | no | SecretAccessKey is the AWS secret access key. |
credentials.sessionToken |
string | no | SessionToken is the session token of temporary credentials; empty for a long-lived key pair. |
credentialsRef |
component ref | no | The AWS credentials the uploads are signed with, as a reference to a secret of kind awsCredentials ("cfg.secret.<id>") whose value carries accessKeyId and secretAccessKey, optionally sessionToken and expiration. Setting it together with the deprecated credentials field is refused. Omit both to sign with what the AWS SDK finds in its own environment. |
keyPrefix is prefixed onto each uploaded Recording's own relative path - it is not a
per-recording template (there is no per-upload substitution). To make it device-specific, bind
${deviceId} from the device's own sys.deviceinfo.main via a resolution config.
timeout = -1 makes the binding wait indefinitely: sys.deviceinfo.main is owned by another
module and may not be published yet when this config is first resolved, and the default timeout of
0 would fail the config immediately instead of waiting for it.
Credentials¶
credentialsRef names a secret of kind awsCredentials holding the key pair the
uploads are signed with. Declare it for this type and set the value out of band:
algol secret set aws_export --kind awsCredentials --consumer recorder.s3upload --from-file ./sts-session.json
The recorder dereferences the reference when it applies the config, so a declared secret with no
value yet parks the profile at WAIT naming it rather than failing an upload later. Setting a new
value re-dispatches the config and rebuilds the transport, which is how a rotation takes effect at
once instead of at the next expiry.
Nothing on the device renews an expired credential set, and nothing can: renewing an STS session
needs an identity allowed to call sts:AssumeRole, and a device holding that would not need the
session in the first place. Whoever mints the session sets the new value on its own schedule. If
no one does, the expiration in the value is what makes the recorder refuse to sign locally -
naming the profile and the expiry - instead of sending a request AWS answers with a 403 that names
neither; the profile then goes to ERROR and pauses. A long-lived key pair carries no
expiration and never expires this way.
Omitting credentialsRef and credentials both signs with whatever the AWS SDK finds in its own
environment.
The credentials literal is deprecated and kept for one schema generation so a deployed profile
keeps uploading across the upgrade. It cannot be migrated into the store - a migration has no
store access, and moving a value there is an operator action - so move it by hand: algol secret
set the value, then update the config to carry credentialsRef and no credentials. Setting
both is refused; preferring one would leave a secret access key sitting on the config plane,
unread, with nothing saying which set is signing.
Start and stop¶
With autoStart = true the profile uploads every Recording as it completes. With
autoStart = false it stays idle - counting what is pending, uploading nothing - until it is
commanded with start and stop (Commands below).
Both are idempotent: a start during a run and a stop while idle are dropped rather than
queued, so a level-matched rule can keep firing them safely. A stop finishes the Recording
being uploaded before it ends the run, so nothing is left half-copied. The reply acknowledges
the command; the profile's exportState telemetry and its config status are what report where
the run actually is (an idle profile rests at WAIT, "awaiting start").
From a trigger these are command actions addressed by the four-segment attribute
ref, never by the subject:
That is what makes a policy like "upload over WiFi, not over GSM" a rule an operator can read and change, instead of a config write on every link change.
Commands¶
| Command | Request | Description |
|---|---|---|
recorder.s3upload.<id>.start |
none | Opens an export run on this S3 upload profile; a start during a run is dropped. |
recorder.s3upload.<id>.stop |
none | Ends the export run on this S3 upload profile once the Recording in hand is finished; a stop while idle is dropped. |
Configuration status¶
The status the recorder publishes for the profile's applied config - the effective status
stage of algol config info <module>.s3upload.<id>. It answers one question: can the profile
run the configuration it was given. Whether the uploads themselves are succeeding is the
separate application status below, and neither axis is derived from the other.
| Status | Message (example) | Meaning | Cleared by |
|---|---|---|---|
DISABLED |
- | enabled = false. The config is stored, no worker runs, nothing is uploaded. |
enabling the profile |
OK |
- | A run is in flight. An autoStart = true profile holds its run open and rests here; an autoStart = false one is here between start and stop. |
the run ending - a stop, or process shutdown. Uploading the whole backlog does not end a run |
WAIT |
awaiting start |
Applied and enabled, no run in flight - an autoStart = false profile before its first start and after every stop. Completed Recordings accumulate; pending counts them. |
a start command |
ERROR |
export profile error: <S3 error code>: <detail> |
Paused on a permanent S3 failure (AccessDenied, AllAccessDisabled, AccountProblem, InvalidAccessKeyId, SignatureDoesNotMatch, InvalidToken, TokenRefreshRequired, ExpiredToken, NoSuchBucket, InvalidBucketName), or on credentials past their own expiration, which the recorder refuses to sign with before any request is sent. Nothing is uploaded and nothing retries. The run stays open, but no further Recording is attempted in it. |
a config update, which rebuilds the worker - for expired credentials, setting a new value on the secret does exactly that |
ERROR |
recstorage is not enabled |
The recorder recognizes no writable recording medium, so there is nothing to upload from. The worker is stopped; the profile's configuration is kept. | recstorage activating a medium again |
ERROR |
error creating s3upload transport: <detail> |
The profile cannot run as configured - a missing bucket, a key prefix the SDK refuses, credentials that will not load, a recording root that is not there. No worker is started and nothing is uploaded. | a corrected config update |
WAIT |
secret cfg.secret.aws_export: no value |
credentialsRef names a declared secret whose value is not set yet, or the configurer did not answer (not declared, unavailable). No worker is started. |
setting the value, which re-dispatches this config |
ERROR |
secret cfg.secret.aws_export is a string secret, needs awsCredentials |
credentialsRef names a secret of the wrong kind, or one whose declaration does not admit recorder.s3upload (not admitted). No value will fix it. |
a corrected config update, or a corrected declaration |
DELETED |
- | The profile was deleted. The worker stops and the component disappears after commit; objects already in the bucket are untouched. | - |
Every ERROR row means the same thing: the profile will not recover on its own, and it takes
a configuration change - this profile's, or recstorage's - to clear it. That is why a
permanent S3 failure is one: bad credentials are a broken configuration whatever the run state
is. A transient failure is not, and neither is a Recording the profile cannot read - the first
is retried, the second skipped, and both leave the profile healthy.
Field validation (bucket, keyPrefix) is refused by the configurer at algol config update:
the command fails, nothing is published, and the profile keeps the effective status it already
had. An unresolved property (${deviceId} in keyPrefix) is reported on the intent status
stage as WAIT - the effective stage stays empty until the resolution succeeds.
Application status¶
Typed status s3upload is registered under this component name.
What the profile reports about its own runtime on the telemetry axis, independent of the
config status above: status is the health code, and exportState (numeric) is where the run
stands.
status |
exportState |
When |
|---|---|---|
OK |
1 disabled |
enabled = false |
OK |
10 idle |
Applied and enabled, never commanded, no run yet |
OK |
4 preparing |
A run has just opened, before the first upload of that run |
OK |
5 exporting |
A run is open - uploading, or waiting for the next completed Recording |
OK |
6 complete |
The run ended on a stop (or process shutdown) |
ERROR |
7 error |
Paused on a permanent S3 failure, or the profile has no worker - recstorage is inactive, or the config was refused |
A transient failure (network, throttling, 5xx) is neither: the Recording stays pending and the
worker retries with exponential backoff, so the profile keeps reporting exporting. A
Recording that cannot be read at all is skipped, counted in skipped, and the profile stays
healthy.
The counters are pending and pendingBytes for the backlog, exported, failed, skipped
and purgedBeforeExport (Recordings retention deleted before this profile had uploaded them)
for the profile's lifetime, and runId, runStarted, runFinished, runExported,
runFailed, runSkipped, runBytesCopied and runCopyMs for the current or last run.
Example¶
algol config update recorder.s3upload.main < s3upload.toml
algol config commit recorder.s3upload.main
Example: key prefix bound from deviceinfo¶
{
"type": "resolution",
"targetConfigType": "s3upload",
"properties": [
{
"type": "configRef",
"name": "deviceId",
"ref": "sys.deviceinfo.main",
"jsonPath": "$.deviceId",
"timeout": -1
}
],
"targetConfig": {
"enabled": true,
"bucket": "my-algol-recordings",
"keyPrefix": "${deviceId}/recordings",
"region": "eu-central-1"
}
}
usbexport¶
Component ref: recorder.usbexport.<id>
Delete: regular (RFC-023) - the component is destroyed and disappears from config ls after commit.
An export profile that copies completed Recordings onto a removable medium - a USB disk an
operator plugs in, waits for, and carries away. It is the same export machinery as
s3upload (same worker, same start/stop commands, same progress ledger); what
differs is that the destination appears and disappears and that a person is standing next to
it.
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be usbexport. |
enabled |
bool | no | Enabled lets the profile run. Disabled, the config is stored, no worker runs and nothing is copied. |
autoStart |
bool | no | AutoStart makes the profile start a run on its own wake-up. False - the zero value and the expected setting - is RFC-024's Option C: nothing in the recorder detects a disk, and a run happens only between local.recorder.cmd.usbexport.<id>.start and .stop, fired by a trigger rule gated on a sys mount event. |
exportDir |
string | when enabled | ExportDir is the mount point of the export medium, bound from the slot's $.targetDir. It must be inside a --allowed-export-dir root; a resolved directory outside every root is a config error and nothing is probed or written. |
destinationId |
string | when enabled | DestinationId is the logical destination export progress is tracked under, bound from the slot's $.diskSelector ("by-label/ALGOL-EXP01"). It is deliberately logical rather than physical: every disk carrying that label is one destination and shares one ledger, which is what makes a relay pair cost only the delta. It is not this system's deviceId. |
pathPrefix |
string | when enabled | PathPrefix is the relative directory under the mount point that everything algol writes lives under (the filesystem counterpart of s3upload's keyPrefix), typically "algol/${deviceId}" so one disk can carry several devices. Foreign content outside it is never read, listed or removed. Must be a valid filesystem path. |
The medium is mounted by systemdmount, never by the recorder: one slot per
profile, matched by filesystem label, with requireFsType pinning exFAT, vfat or ext4. Both
bound values come from that one slot, which is what keeps them in step - a profile can never
track progress under one destination identity while writing into another slot's directory. The
recorder module needs at least one --allowed-export-dir root, and a resolved exportDir
outside every root is refused as a config error before anything is probed or written.
Preparation is a label, and nothing else. Format the disk, set the filesystem label the slot selects on, done. No per-disk registration on the device, and no config change when a disk in a rotating pair is replaced.
destinationId is logical, not physical. Every disk carrying the label is one destination
and shares one progress record, so a rotating pair costs only the delta each trip (relay).
Give two disks different labels, slots and profiles instead and each independently receives
everything retained (mirror). Each physical disk gets its own mediumId, minted into its
manifest the first time this device writes to it - destinationId says which destination,
mediumId which disk, and neither is this system's deviceId.
On the disk¶
<exportDir>/<pathPrefix>/
manifest.jsonl header + one line per Recording the disk holds
runs/<runId>.json per-run receipt
recordings/<...>.parquet copied Recordings, in the recording medium's relative layout
Everything is written under pathPrefix - typically algol/${deviceId}, so one disk can carry
several devices - and nothing outside it is ever read, listed or removed. Each copy is written
to a _tmp_ name, fsynced, renamed, its parent directory fsynced, and only then recorded in the
manifest, so a Recording that appears in the manifest is one the disk really holds. The manifest
line carries the digest of the bytes as they were written, for whoever reads the disk to check
against; the recorder does not read the copy back, because on a buffered mount that re-hashes
the page cache rather than the medium. The run report is the durable receipt: telemetry leaves
with the disk, and that file is what an operator can open on a laptop afterwards.
Removing the disk¶
A run reports complete once everything it copied is durable - every copy, its parent
directory, the manifest and the run report are fsynced as they are written, so the receipt is
on the disk before the state changes - but that is not the signal to unplug. What is left
is the filesystem's own metadata, and the unmount is what flushes it: a release syncs the
volume and marks it clean, which is strictly more than the recorder could do from inside the
mount. Removal therefore waits for local.sys.cmd.systemdmount.<slot>.release, which unmounts
the slot without disabling its config and re-arms on the next insert. This holds on every
filesystem, ext4 included: the disk in an operator's hand does not advertise its filesystem,
and on FAT pulling a mounted disk can damage files from earlier runs that were already
copied.
Who calls it is a deployment decision: a trigger turns the profile's terminal
exportState into the release command, and another turns it into an LED. Without those rules
the export still works correctly - but nothing releases the medium and nobody is told anything,
which is a technician workflow rather than an operator one.
Commands¶
| Command | Request | Description |
|---|---|---|
recorder.usbexport.<id>.start |
none | Opens an export run on this USB export profile; a start during a run is dropped. |
recorder.usbexport.<id>.stop |
none | Ends the export run on this USB export profile once the Recording in hand is finished; a stop while idle is dropped. |
Configuration status¶
The status the recorder publishes for the profile's applied config - the effective status
stage of algol config info recorder.usbexport.<id>. It answers one question: can the profile
run the configuration it was given. Whether the last run actually succeeded is the separate
application status below, and neither axis is derived from the other.
| Status | Message (example) | Meaning | Cleared by |
|---|---|---|---|
DISABLED |
- | enabled = false. The config is stored, no worker runs, nothing is probed or exported. |
enabling the profile |
OK |
- | A run is in flight: the medium is being prepared, or Recordings are being copied. | the run ending - a stop, a halt, or process shutdown. Draining the backlog does not end a run; it stays open until something stops it |
WAIT |
awaiting start |
Applied and enabled, no run in flight. The resting state of an autoStart = false profile before its first start and after every clean stop. |
a start command (or, with autoStart = true, the next wake-up) |
WAIT |
export run halted (absent): no medium mounted at <dir>, or export run halted (absent): export directory <dir> does not exist |
A run was commanded but the slot holds nothing. Not a fault - an unplugged disk is this feature's resting state. | the next start |
WAIT |
export run halted (permission): cannot write to <dir>: <errno> - a FAT medium needs uid= and gid= in the mount slot's options |
Something is mounted, but the run-start write probe cannot write to it - almost always missing uid= / gid= on the mount slot. |
the next start, once the slot is fixed |
WAIT |
export run halted (readonly): medium at <dir> is mounted read-only |
The medium is mounted read-only: a write-protect switch, or a dirty filesystem the kernel refused to mount writable. | the next start |
WAIT |
export run halted (nospace): medium at <dir> is full, or an ENOSPC during a copy |
The destination ran out of space. Everything already copied and acknowledged stays valid; the rest is the next run's backlog. | the next start, in practice on a disk with space |
WAIT |
export run halted (gone): <detail> |
The medium vanished mid-run (EIO, ENOENT, a stale handle). This is what pulling a disk without a release looks like. |
the next start |
WAIT |
export run halted (manifest): <detail> |
The manifest could not be opened, appended to or fsynced. A copy that is on the disk but not in its manifest is invisible to the next run, so the run stops rather than continue. | the next start |
WAIT |
export run halted (other): <detail> |
A medium failure the transport could not classify - for example export directory <dir> is not a directory. |
the next start |
ERROR |
recstorage is not enabled |
The recorder recognizes no writable recording medium, so there is nothing to export from. The worker is stopped; the profile's configuration is kept. | recstorage activating a medium again |
ERROR |
error creating usbexport transport: <detail> |
The profile cannot run as configured. No worker is started and the medium is neither probed nor written. See the list below. | a corrected config update |
DELETED |
- | The profile was deleted. The worker stops and the component disappears after commit; nothing on the medium is touched. | - |
A medium problem never reaches config ERROR. On this axis ERROR means the configuration
cannot work at all, and always means no worker is running - a full, read-only or missing disk
says nothing about whether the configuration is right, so it rests at WAIT with the reason in
the message and reports the fault on the application axis instead.
error creating usbexport transport: covers:
no --allowed-export-dir is configured, so no usbexport profile can be applied- the recorder module was started without the flag, so the feature is off entirely.export directory <dir> is not inside any --allowed-export-dir root (<roots>)-exportDirresolved outside every allowlisted root.export directory is empty, orcannot resolve export directory <dir>: <detail>.export directory <dir> is already used by export profile <ref>, ordestination identity <id> is already used by export profile <ref>- two profiles would write one medium under two progress records.pathPrefix is required,destinationId is required,invalid pathPrefix: <detail>- a disabled shell config that was enabled without filling the operational fields in.data directory does not exist or is not a directory- the recording root the copies are read from is gone.
Two things never appear as an effective status:
- Field validation (
exportDir,destinationId,pathPrefix) is refused by the configurer atalgol config update. The command fails, nothing is published, and the profile keeps the effective status it already had. - An unresolved binding -
exportDiranddestinationIdcome fromsys.systemdmountinfo.<slot>- is reported on theintent statusstage asWAIT. The effective stage stays empty until the resolution succeeds, because nothing has been dispatched to the recorder yet.
Application status¶
Typed status usbexport is registered under this component name.
What the profile reports about its own runtime on the telemetry axis, independent of the
config status above: status is the health code, and exportState (numeric) is where the run
stands. A trigger rule is written against these.
status |
exportState |
When |
|---|---|---|
OK |
1 disabled |
enabled = false |
OK |
10 idle |
Applied and enabled, never commanded, no run yet |
OK |
4 preparing |
A run opened: the medium is being classified, swept and its manifest read |
OK |
5 exporting |
Copying |
OK |
6 complete |
The run ended on a stop (or process shutdown) with no medium failure, and its receipt is written. Copying the whole backlog does not end a run - a stop is what makes a profile reach a terminal state a release rule can match |
OK |
2 absent |
The run halted because nothing was mounted. Not a fault: an unplugged disk must not page a fleet |
OK |
3 notWritable |
The run halted on a medium that was looked at and refused: read-only, or the write probe denied |
ERROR |
7 error |
The run halted on a fault (nospace, gone, manifest, other, or a permission failure mid-copy), or the profile has no worker - recstorage is inactive, or the config was refused |
A profile that has never been commanded reports idle, not absent - "no run yet" and "I
looked and found no disk" are different facts. Values 8 and 9 are reserved for the claim
gate and are never reported.
errorReason names why the last run halted: absent, readonly, permission, nospace,
gone, manifest, other, and none when no run has halted. (verify is retired - the
read-back it belonged to was removed - and its number is kept reserved rather than reused, so
an old measurement never reads as a different reason.)
The rest of the fields are what a stop or release rule is written against: pending and
pendingBytes for the backlog, exported, failed, skipped and purgedBeforeExport for
the profile's lifetime, runId, runStarted, runFinished, runExported, runFailed,
runSkipped, runConflicts, runBytesCopied and runCopyMs for the current or last run, and
mediumId, destinationId, destinationCapacityBytes, destinationFreeBytes and
destinationUsage for the disk itself. The destination figures are refreshed after each copy,
so they are as of the last Recording written rather than the last telemetry tick.
Example¶
algol config update recorder.usbexport.stick01 < usbexport.toml
algol config commit recorder.usbexport.stick01
Example: bound to a mount slot¶
type = "resolution"
targetConfigType = "usbexport"
[[properties]]
type = "configRef"
name = "exportDir"
ref = "sys.systemdmountinfo.disk01"
jsonPath = "$.targetDir"
timeout = 10
[[properties]]
type = "configRef"
name = "destinationId"
ref = "sys.systemdmountinfo.disk01"
jsonPath = "$.diskSelector"
timeout = 10
[[properties]]
type = "configRef"
name = "deviceId"
ref = "sys.deviceinfo.main"
jsonPath = "$.deviceId"
timeout = -1
[targetConfig]
enabled = true
autoStart = false
exportDir = "${exportDir}"
destinationId = "${destinationId}"
pathPrefix = "algol/${deviceId}"
{
"type": "resolution",
"targetConfigType": "usbexport",
"properties": [
{
"type": "configRef",
"name": "exportDir",
"ref": "sys.systemdmountinfo.disk01",
"jsonPath": "$.targetDir",
"timeout": 10
},
{
"type": "configRef",
"name": "destinationId",
"ref": "sys.systemdmountinfo.disk01",
"jsonPath": "$.diskSelector",
"timeout": 10
},
{
"type": "configRef",
"name": "deviceId",
"ref": "sys.deviceinfo.main",
"jsonPath": "$.deviceId",
"timeout": -1
}
],
"targetConfig": {
"enabled": true,
"autoStart": false,
"exportDir": "${exportDir}",
"destinationId": "${destinationId}",
"pathPrefix": "algol/${deviceId}"
}
}
The properties bind to a mount slot, and a commit does not need the disk attached: what is
written is the intent, property references and all, whether the slot is WAIT or OK.
secret¶
Component ref: cfg.secret.<id>
Delete: regular (RFC-023) - the component is destroyed and disappears from config ls after commit.
The declaration of a secret (RFC-001): what its value is (kind) and which config types may reference it (consumers). The declaration is metadata only. The value never travels the configuration API - it goes in and out through the secret's own command subject, local.cfg.cmd.secret.<id>.set / .get (core NATS request-reply, confined to the requester's inbox by the bus), and is stored by the configurer under --secret-dir. config ls, config info, the web UI, exports and bundles show the declaration and the references, and have no value to show - the web UI's page for a declaration does carry a form that sets one, per kind, which makes the same set request as the CLI and reads nothing back.
On delete the declaration and the stored value are removed; consumers that still reference it withdraw the value they applied and go to WAIT.
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be secret. |
kind |
string | yes | What the value is: string (one line of text - a key, a token), bytes (arbitrary bytes - a credentials file, a certificate), bcrypt (a password, as the bcrypt hash its writer made of it) or awsCredentials (a JSON object with accessKeyId and secretAccessKey, optionally sessionToken and expiration). A consumer asks for the value as its kind; a string secret referenced where bytes are needed, or the reverse, is a configuration error on the consumer. One of string, bytes, bcrypt, awsCredentials. |
consumers |
string array | yes | The config types that may reference this secret, as component groups moduleId.componentName (e.g. sys.wlan). At least one. The declaration is the grant: a config of any other type that references this secret is refused. At least 1 item. |
description |
string | no | Free text for config ls and the web UI. |
A bcrypt value is the password's hash in modular-crypt form of cost 10 or more - the writer hashes, see algol secret set --bcrypt. An awsCredentials value is a JSON object carrying a non-empty accessKeyId and secretAccessKey, optionally a sessionToken and an expiration (RFC3339); members it does not name are kept, so a renewal pipeline that adds one of its own is not refused by a device a release behind. A value is 1 byte to 64 KiB whatever its kind.
A body that carries a value is refused on submission; in a startup file the field is ignored, since a value is not a declaration.
A consumer references a secret in a typed *Ref field naming the component ("pskRef": "cfg.secret.wifi_site"), validated to name a secret component; the reference and the declaration's consumers must agree before the value flows. A configRef property pointing at a secret resolves to the declaration - kind, consumers, description - never to a value.
The value endpoints and the store are the configurer's (Configurer, Messaging); the consumer's statuses are documented on each consumer type; the algol secret verbs are the CLI's (CLI).
Commands¶
| Command | Request | Description |
|---|---|---|
cfg.secret.<id>.get |
none | Reads a secret's value, answered only into the requester's own inbox and only to a requester a dispatched config admits to it. |
cfg.secret.<id>.set |
JSON, signed | Stores a declared secret's value, answered only into the requester's own inbox; the reply says when and how many bytes, never the value. |
Configuration status¶
The effective stage, reported by the configurer's own handler:
| Status | Message (example) | Meaning | Cleared by |
|---|---|---|---|
WAIT |
no value |
Declared, nothing was set yet - a bundle activated on a fresh device |
a set request |
OK |
value set 2026-08-30T14:02:11Z (28 bytes) |
A value is stored; the message is the write confirmation, never a fingerprint of the value | - |
ERROR |
store: <os error> |
The value file cannot be read (a missing --secret-dir mount, a damaged disk) |
the store becoming usable, then a set or a re-apply of the declaration |
ERROR |
value file is 70000 bytes, limit is 65536 |
A file dropped by hand exceeds the bound | a set within it |
ERROR |
value file: <reason> |
A file dropped by hand, or a kind change, leaves the value invalid for the declared kind (a string with a newline or not UTF-8, a bcrypt value that is not a bcrypt hash of cost 10 or more, an awsCredentials value without its key pair) |
a set of a valid value, or the kind that fits the file |
Never reaching the effective stage: a kind outside the closed set, a consumers entry that is not a two-part component group, a body carrying a value, a declaration dropping a group a dispatched config still references (consumers must keep admitting <group> (referenced by <ref>)), and a declaration written by a module identity (403: declaring is a person's, RFC-001 D10).
Application status¶
No typed status is registered under this component name.
A secret has no runtime.
Example¶
secomea¶
Component ref: secomea.secomea.main
Delete: singleton (RFC-023) - the component is reset to the module's startup default and keeps answering.
See Secomea for operational details.
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be secomea. |
enabled |
bool | no | Enabled runs the sitemanager. A disabled config may be an incomplete shell: domain, servers and name are required only when enabled. |
domain |
string | when enabled | Domain is the Secomea domain the appliance belongs to (Domain= in sme.txt). At least 3 characters. |
servers |
string array | when enabled | Servers are the GateManager addresses, host names or IPs (Server= in sme.txt). At least 1 item. |
name |
string | when enabled | Name is the appliance's name in the GateManager (Name= in sme.txt). At least 1 character. |
agents |
string array | no | Agent lines as they appear in sme.txt (Agent<n>=...), e.g. agent=ZZ:SSH name="SSH" sn="#01" cfg="127.0.0.1". Any string is accepted; the sitemanager is what interprets it. The GateManager may add or change agents while connected, so the reported value can differ from the one that was sent. |
Fields are merged over what sme.txt already holds rather than replacing it, and the effective
status carries the file as re-read: agents can therefore include lines the GateManager
provisioned that the update never sent, and a partial update keeps the fields it omits.
Configuration status¶
The effective stage, as secomea reports it.
| Status | Message (example) | Meaning | Cleared by |
|---|---|---|---|
DISABLED |
- | enabled = false and the sitemanager is stopped. servers/domain/name/agents stay in sme.txt, so re-enabling keeps them. A disabled config whose sitemanager is somehow still up reports the running rows below instead |
enabling it |
OK |
- | enabled, and sitemanager status reports GREEN (connected to the GateManager) or BLUE (a GoToAppliance session is active) |
the connection dropping, seen at the next status read |
WAIT |
- | enabled and, inside the connect grace window that an applied update opens (--connect-timeout, default 60 s), the sitemanager is not running yet, is not connected yet, or its status could not be read |
the background poller republishing once connected (OK), or the window elapsing (whatever the status is by then) |
ERROR |
secomea is not running |
enabled, but sitemanager status reports it stopped and the grace window has elapsed |
the next update, which starts it; an unclean exit ends the module, and its restart re-applies the config |
ERROR |
the last line of sitemanager status, e.g. Not connected |
enabled and running, but neither GREEN nor BLUE (typically RED), and the grace window has elapsed | the connection coming up, seen at the next status read - an update, or an sme.txt change picked up by the poll |
Never reaches the effective stage: an unreadable sme.txt, a sitemanager status that cannot
be run or that times out outside the grace window, a failed rewrite or restart, and a merged
body that fails validation (domain/servers/name are required once enabled, so enabling
against an empty sme.txt without them is refused). Those are system errors - published as
ERROR with version 0 to the requester only, leaving the previous effective status in place.
Application status¶
No typed status is registered under this component name.
Published under secomea.app.main (app) on every
status tick; there is no on-change emit. status is the remote-access state, derived from
sitemanager status alone and independent of the configuration status above: DISABLED while
the config is disabled, WAIT while connecting inside the grace window, ERROR when the
sitemanager should be connected but is stopped or not connected, UNKNOWN while its status
cannot be read, and OK while connected. The command runner's own counters are published under
secomea.runner.main (runner).
Example¶
systemdapp¶
Component ref: <module>.systemdapp.<id>
Delete: regular (RFC-023) - the component is destroyed and disappears from config ls after commit.
Runs a binary inside a read-only root filesystem image, sandboxed via systemd.
Security
A systemdapp config runs an arbitrary binary from a rootfs image on the device. Isolation reduces host exposure but does not make the workload harmless - it can still use whatever users, environment variables, and bind mounts the config grants. In listen mode, enabling the config allows repeated starts via NATS. Only deploy rootfs images and configs from trusted sources; verify checksums and restrict who can update *.systemdapp.* components.
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be systemdapp. |
enabled |
bool | no | Enabled controls whether the workload is active. When true it is started immediately. |
rootfs |
string | no | Rootfs is the host path to a read-only root filesystem image (squashfs, erofs, ext4, ...). It is mounted loop,ro with auto-detected type and becomes the workload's root directory. The bound value is a plain literal string: property references are materialized on the configurer via a resolution config (RFC-022) - inline "$name" tokens or the { "$value": "..." } binding slot - never rendered inside the sys module. |
exec |
string | no | Exec is the absolute path to the binary to run inside the rootfs, e.g. "/usr/bin/myapp". |
args |
string array | no | Args are arguments to Exec (plain literals; see Rootfs). |
env |
string array | no | Env are environment variables passed to the workload in KEY=VALUE form (plain literals; see Rootfs). |
user |
string | no | User is the unix user (optionally "user:group") the workload runs as. Defaults to "nobody". |
restartDelay |
int | no | RestartDelay is the number of seconds to wait before restarting the workload on failure. Zero (default) disables automatic restarts. |
executionMode |
string | no | ExecutionMode controls when the workload runs. ExecutionModeStart (default) starts it automatically when Enabled=true. ExecutionModeListen waits for a NATS request on local.{moduleId}.cmd.systemdapp.{componentId}.start before mounting and running. One of start, listen. |
rootfs, args and env may reference properties when the config is authored as a resolution config, which is where the bindings live.
In listen mode the start command starts the workload; in any mode restart restarts its unit (Commands below). See Messaging and restart.
Commands¶
| Command | Request | Description |
|---|---|---|
sys.systemdapp.<id>.restart |
none | Restarts this app. |
sys.systemdapp.<id>.start |
none | Runs this listen-mode app now: mounts its rootfs and starts it. |
Configuration status¶
The status sys publishes for the applied config - the effective status stage of
algol config info <module>.systemdapp.<id>. For this type it is the only status there is:
the unit's own state is folded into it, because a transient unit has no separate health
component (see the application status below).
WAIT means the outcome is not decided yet and the module will decide it without further
input; ERROR means it is decided and needs a corrected config.
| Status | Message (example) | Meaning | Cleared by |
|---|---|---|---|
DISABLED |
- | enabled = false. The config is stored and no unit runs. Operational fields (rootfs, exec) are not checked, so a disabled config may be an incomplete shell. |
enabling it |
OK |
- | Applied and settled: in start mode the unit is active, in listen mode nothing runs until a NATS trigger arrives. A start-mode workload that has exited is also OK - a run that finished is not a fault. |
the unit failing, or the next update |
WAIT |
unit starting: <...> |
The transient unit was created and systemd has not yet reported it active. | systemd reporting the unit active (OK) or failed (ERROR) |
WAIT |
(the pending config's own reason) | A new config arrived while the previous unit is still loaded. It is applied once that unit is gone. | the old unit stopping |
WAIT |
the unresolved property's message | A configRef property has not resolved yet. With timeout = -1 it waits indefinitely rather than failing. |
the referenced component publishing a value |
ERROR |
rootfs is required, exec is required, exec must be an absolute path inside the rootfs: <path>, rootfs not accessible: <detail> |
Refused before any unit is created. The rootfs is checked on the host, so a path that is not there yet fails rather than waits. | a corrected update |
ERROR |
service unit failed: <unit>, service unit failed (failed to load): <unit>, service unit failed (is being restarted): <unit> |
systemd reports the unit as failed, unloadable, or in an auto-restart loop. The last one is what a crashing workload with restartDelay set looks like. |
a corrected update, or the unit recovering on its own |
ERROR |
failed to start transient unit: <detail>, timeout waiting for unit start, failed to stop unit: <detail> |
The D-Bus call that creates or removes the unit failed or did not settle. | a corrected update |
ERROR |
a failed property reference | A property that resolved to an error is terminal and outranks a pending WAIT (ADR-010 D4). |
a corrected update |
DELETED |
- | The unit is stopped and the component disappears after commit. | - |
Application status¶
Typed status systemdapp is registered under this component name.
There is no health axis for this type: the measurement on <module>.systemdapp.<id> carries
updateCount (configs applied) and startCount (units started) and no status field. Read
the configuration status above for whether the workload is running - which is why WAIT and
ERROR are separated as carefully as they are there.
The owning module's own health is <module>.app.main, and it says nothing about an individual
workload.
Example¶
Example: rootfs bound from a fileinfo¶
A literal rootfs path is a plain systemdapp config. To source the path from another
component, author a resolution config and reference the
property in rootfs with the $rootfsRef token, bound on the configurer.
systemdmount¶
Component ref: sys.systemdmount.<slot>
Delete: regular (RFC-023) - the component is destroyed and disappears from config ls after commit.
A managed host mount at a pool slot (disk00 .. disk09). The mount point is <poolRoot>/<slot> and is not itself configurable. The recorder never mounts or unmounts - a usbexport or recstorage profile binds this slot's systemdmountinfo.
On delete the transient mount unit is stopped, so the slot is unmounted; the medium and its contents are untouched.
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be systemdmount. |
enabled |
bool | no | Enabled mounts the slot's medium whenever it is present. Disabled, the slot stays unmounted. |
diskSelector |
string | no | DiskSelector is an opaque /dev/disk/by-* selector, e.g. "by-label/ALGOL-REC". Never a kernel device name (/dev/sda1). Set when the slot is created and fixed for its life: the component id is the slot, so pointing the same slot at another disk is not an edit, it is a different slot. Delete the config and create it again to change it. Write-once: fixed when the component is created. |
options |
string | no | Options are mount options passed to the transient .mount unit (e.g. "noatime"). |
requireFsType |
string | no | RequireFsType, when set, refuses to mount unless the probed filesystem type matches. |
On FAT, options typically carries uid= / gid= so the recorder can write.
diskSelector is write-once (RFC-035 D15): the ref names the slot and the body names the disk in it, so a write grant on sys.systemdmount.disk01 is only as narrow as this field is fixed. An update that changes it is refused with a 400 naming the value it is fixed at - for every requester, keymaster included, since this says something about the resource rather than about the sender. An update that omits it keeps the stored value, so options and requireFsType are edited normally. To point the slot at another disk, delete the config and create it again; that is visible where a body edit is not - the ref leaves config ls, and the module sees a CONFIG_DELETE.
Release¶
The release command (Commands below) unmounts without disabling the config, so an operator can swap media. The command acknowledges immediately ("ok"). The unmount runs asynchronously. Config status is the answer: WAIT unmounting... then WAIT released, waiting for removal (or ERROR if the unmount failed). Do not wait for the reply to mean the disk is safe to pull.
A trigger typically fires release from a usbexport terminal exportState. See Messaging.
Events: disk_mounted / disk_unmounted.
Commands¶
| Command | Request | Description |
|---|---|---|
sys.systemdmount.<id>.release |
none | Unmounts the medium in this slot and leaves the slot enabled, so the next medium mounts on its own; the unmount runs in the background and reports through the slot's status. |
Configuration status¶
| Status | Message (example) | Meaning | Cleared by |
|---|---|---|---|
DISABLED |
- | enabled = false. No mount is started |
enabling |
WAIT |
device ... not present | nothing matching diskSelector is plugged in |
inserting the disk |
WAIT |
unmounting... | a release was accepted; unmount is in flight |
unmount completing |
WAIT |
released, waiting for removal | unmount finished; safe to pull. Survives a config update so a restart does not remount | the medium going away, then the next insert |
WAIT |
filesystem type mismatch | probed type differs from requireFsType |
the right medium, or a corrected field |
ERROR |
release failed: ... | the unmount failed. Not WAIT - for a mount slot, WAIT means safe to pull |
a successful retry, or removing the medium |
OK |
- | mounted, and the device is still present | - |
A yanked disk without a release is re-queued, not reported OK with nothing mounted.
Never reaching the effective stage: an update changing diskSelector, and one binding it to a property ($value / configRef) - both are refused in the write's own answer, so no status is published for them.
Application status¶
Typed status systemdmount is registered under this component name.
The slot's health is the config axis above. Element-level LED / trigger rules should match config status (or the events) rather than inventing a second mounted flag.
Example¶
algol config update sys.systemdmount.disk01 < systemdmount.toml
algol config commit sys.systemdmount.disk01
systemdmountinfo¶
Component ref: sys.systemdmountinfo.<slot>
Read-only. Published by the module; not writable through the config store.
Derived from a systemdmount config. This is what usbexport binds so exportDir and destinationId stay in step.
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be systemdmountinfo. |
enabled |
bool | no | Enabled mirrors the mount config's enabled. |
diskSelector |
string | no | DiskSelector mirrors the mount config's /dev/disk/by-* selector. |
options |
string | no | Options mirrors the mount config's mount options. |
requireFsType |
string | no | RequireFsType mirrors the mount config's filesystem requirement. |
targetDir |
string | no | TargetDir is the slot's mount point, <poolRoot>/<slot> - what an export profile binds as its exportDir. |
mountRef |
component ref | no | MountRef is the writable systemdmount component this mirrors. |
Application status¶
No typed status is registered under this component name.
The slot's state is the systemdmount config axis.
systemdscript¶
Component ref: <module>.systemdscript.<id>
Delete: regular (RFC-023) - the component is destroyed and disappears from config ls after commit.
Security
A systemdscript config executes shell code on the host through a systemd-managed unit in the owning module's context. Scripts can invoke commands, read and write paths the module can reach, and pass arbitrary environment variables. In listen mode, enabling the config allows repeated execution via NATS. Only deploy scripts from trusted sources; review filename, args, env, and any template-driven properties before enabling.
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be systemdscript. |
filename |
string | when enabled | Filename is the script path passed to the interpreter (required). The bound value is a plain literal string: property references are materialized on the configurer via a resolution config (RFC-022) - inline "$name" tokens or the { "$value": "..." } binding slot - never rendered inside the sys module. |
args |
string array | no | Args are additional arguments appended after the script filename (plain literals; see Filename). |
interpreter |
string | when enabled | Interpreter selects the shell used to run the script. Required; must be a known interpreter (currently only "bash"). An unset/unknown interpreter fails validation. One of unknown, bash. |
env |
string array | no | Env are extra environment variables passed to the script in KEY=VALUE form (plain literals; see Filename). |
enabled |
bool | no | Enabled controls whether the script is active. For ExecutionModeStart the script is started immediately when true. For ExecutionModeListen it gates NATS-triggered execution. |
restartDelay |
int | no | RestartDelay is the number of seconds to wait before restarting the script on failure. Zero (default) disables automatic restarts. Only applies to ExecutionModeStart. |
executionMode |
string | no | ExecutionMode controls when the script is executed. ExecutionModeStart (default) starts the script automatically when Enabled=true. ExecutionModeListen waits for a NATS request on local.{moduleId}.cmd.systemdscript.{componentId}.start before executing. One of start, listen. |
filename, args and env may reference properties when the config is authored as a resolution config, which is where the bindings live.
In listen mode the start command starts the script; in any mode restart restarts its unit (Commands below). See Messaging and restart.
Commands¶
| Command | Request | Description |
|---|---|---|
sys.systemdscript.<id>.restart |
none | Restarts this script: a start-mode script is run again, a running one is bounced. |
sys.systemdscript.<id>.start |
none | Runs this listen-mode script now. |
Configuration status¶
The status sys publishes for the applied config - the effective status stage of
algol config info <module>.systemdscript.<id>. For this type it is the only status there is:
the unit's own state is folded into it, because a transient unit has no separate health
component (see the application status below).
WAIT means the outcome is not decided yet and the module will decide it without further
input; ERROR means it is decided and needs a corrected config.
| Status | Message (example) | Meaning | Cleared by |
|---|---|---|---|
DISABLED |
- | enabled = false. The config is stored and no unit runs. filename, interpreter and the script itself are not checked, so a disabled config may be an incomplete shell. |
enabling it |
OK |
- | Applied and settled: in start mode the unit is active, in listen mode nothing runs until a NATS trigger arrives. A start-mode script that has exited is also OK - a script that ran to completion is not a fault. |
the unit failing, or the next update |
WAIT |
unit starting: <...> |
The transient unit was created and systemd has not yet reported it active. | systemd reporting the unit active (OK) or failed (ERROR) |
WAIT |
(the pending config's own reason) | A new config arrived while the previous unit is still loaded. It is applied once that unit is gone. | the old unit stopping |
WAIT |
the unresolved property's message | A configRef property has not resolved yet - typically filename bound from a fileinfo component. With timeout = -1 it waits indefinitely rather than failing. |
the referenced component publishing a value |
ERROR |
unsupported interpreter: <name> |
interpreter is anything other than bash. |
a corrected update |
ERROR |
script filename not set, script file not accessible: <detail>, bash syntax error: <detail> |
Checked before any unit is created: the script must exist and parse. A syntax error is refused here rather than left to fail at runtime. | a corrected update, or fixing the script on disk and re-applying |
ERROR |
service unit failed: <unit>, service unit failed (failed to load): <unit>, service unit failed (is being restarted): <unit> |
systemd reports the unit as failed, unloadable, or in an auto-restart loop. A script exiting non-zero lands here; the last one is what a crash loop with restartDelay set looks like. |
a corrected update, or the unit recovering on its own |
ERROR |
failed to start transient unit: <detail>, timeout waiting for unit start, failed to stop unit: <detail> |
The D-Bus call that creates or removes the unit failed or did not settle. | a corrected update |
ERROR |
a failed property reference | A property that resolved to an error is terminal and outranks a pending WAIT (ADR-010 D4). |
a corrected update |
DELETED |
- | The unit is stopped and the component disappears after commit. | - |
Application status¶
Typed status systemdscript is registered under this component name.
There is no health axis for this type: the measurement on <module>.systemdscript.<id> carries
updateCount (configs applied) and startCount (units started) and no status field. Read
the configuration status above for whether the script is running, failed, or done.
The owning module's own health is <module>.app.main, and it says nothing about an individual
script.
Example¶
algol config update sys.systemdscript.setup < systemdscript.toml
algol config commit sys.systemdscript.setup
Example: script bound from a fileinfo¶
The script path is sourced from a fileinfo component, so the config is authored as a
resolution config (type = "resolution",
targetConfigType = "systemdscript").
{
"type": "resolution",
"targetConfigType": "systemdscript",
"enabled": true,
"filename": "$cmdFileInfoRef",
"interpreter": "bash",
"args": ["--verbose"],
"env": ["MY_VAR=value"],
"properties": [
{
"type": "configRef",
"name": "cmdFileInfoRef",
"ref": "sys.fileinfo.setup_sh",
"jsonPath": "$.filename",
"timeout": -1
}
]
}
systemdunit¶
Component ref: sys.systemdunit.<id>
Delete: regular (RFC-023) - the component is destroyed and disappears from config ls after commit.
Maps a systemd unit the host already has into algol, so it can be observed and operated: started, stopped and restarted. algol never writes, generates, masks, enables or disables a unit file - the unit comes from the operating system, and this config only says that algol addresses it. Nothing about the unit is asserted at apply, so re-applying a config never disturbs a running unit, and disabling or deleting one leaves the unit exactly as it is.
The component id is a readable alias you choose (sys.systemdunit.tailscale), not the unit name, which is never a legal component reference. The unit is named in the body instead.
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be systemdunit. |
enabled |
bool | no | Enable algol's mapping of the unit. While disabled, the unit is reported no further and its commands are refused; the unit itself keeps running, or not running, exactly as it was. |
unit |
string | yes | The host unit name, suffix included: "tailscaled.service", "getty@tty1.service", "var-log.mount". Set when the component is created and fixed for its life - addressing another unit is a different component, so delete this one and create it again. Device units cannot be named: nothing can start or stop one. Write-once: fixed when the component is created. |
unit is write-once (RFC-035 D15): the reference is an alias, so the body is the only place the unit is named, and a grant on sys.systemdunit.tailscale is only as narrow as this field is fixed. An update that changes it is refused with a 400 naming the value it is fixed at - for every requester, keymaster included - and one that omits it keeps the stored value. To address another unit, delete the config and create it again. Unlike most fields on a type with an enable concept, unit is required even when the config is disabled: a write-once field is settable only as the component is created, so one created without a unit could never be given one.
The three commands below take no body; an unknown component answers 404. They are the grantable form of the module-level sys.systemd.ctl.restart, which names its target in the request body and so can never appear in a role's derived permissions. A role reaches them through its exec set - "exec": ["sys.systemdunit.tailscale.*"] is "may operate tailscaled and nothing else" - and needs no write on the component. systemdunit is a privileged type on the write and manage sets: choosing which host unit a component addresses is a built-in role's act.
Commands¶
| Command | Request | Description |
|---|---|---|
sys.systemdunit.<id>.restart |
none | Restarts this host unit. Refused with 409 while the unit's config is disabled or systemd does not have the unit. |
sys.systemdunit.<id>.start |
none | Starts this host unit. Refused with 409 while the unit's config is disabled or systemd does not have the unit. |
sys.systemdunit.<id>.stop |
none | Stops this host unit. Refused with 409 while the unit's config is disabled or systemd does not have the unit. |
Configuration status¶
| Status | Message (example) | Meaning | Cleared by |
|---|---|---|---|
OK |
- | systemd has the unit loaded | - |
ERROR |
unit <name> not found |
systemd does not have the unit; it is not in the image, or the name is wrong | A corrected component, or the unit appearing |
ERROR |
unit <name> is masked |
The unit is masked on the host, so nothing can start it | An operator unmasking it |
ERROR |
unit <name> could not be loaded: <state> |
systemd has the unit's definition but refused it, e.g. bad-setting |
Fixing the unit file on the host |
DISABLED |
- | enabled: false |
Enabling the config |
Nothing reaches WAIT: algol issues no asynchronous assertion, so there is no in-flight state to report. A unit systemd does not have is an ERROR rather than something to wait for - unlike a systemdmount slot, which is designed around a medium that comes and goes, a unit is in the image or it is not.
Never reaching the effective stage: an update changing unit, and one binding it to a property ($value / configRef) - both are refused in the write's own answer, so no status is published for them.
Application status¶
Typed status systemdunit is registered under this component name.
Published on the collector tick and additionally the moment the unit's state changes, so a transition shorter than the publish interval is still observable by a trigger.
| Field | Meaning |
|---|---|
status |
OK while the unit can be operated, ERROR while systemd does not have it or it sits in the failed state, DISABLED for a disabled config. A merely stopped unit is OK |
unitActive |
systemd's active state: active, inactive, failed, activating, deactivating, reloading |
unitSubState |
The finer state within it, depending on unit type: running, exited, dead, mounted |
loadState |
Whether systemd has the definition: loaded, not-found, masked, bad-setting, error |
unitFileState |
Whether the unit starts at boot: enabled, disabled, static, masked, ... Observed, never asserted |
activeSince |
Unix seconds when the unit last became active, 0 while it is not |
startCounter, stopCounter, restartCounter |
Commands algol has sent since the process started |
autoRestartCounter |
How often systemd itself restarted the unit - the signal that it is flapping rather than being restarted by anybody. Absent for a unit type that keeps no such count |
The unit a component addresses is deliberately not on the status: what an operator wrote stays in the config, and a consumer maps from the component reference.
Example¶
algol config update sys.systemdunit.tailscale < systemdunit.toml
algol config commit sys.systemdunit.tailscale
A role that may operate it, and nothing else:
teapot¶
Component ref: cfg.teapot.<id>
Delete: regular (RFC-023) - the component is destroyed and disappears from config ls after commit.
A probe config with no effect on the device. The configurer holds it and derives the read-only
teapotinfo mirror; nothing else happens. Use it to exercise the configuration
path itself - write, commit, patch, bind a property into, disable, delete - against a running
device with nothing behind it to break. It is also the stand-in type the ADRs and RFCs use in
their examples.
On delete the config is destroyed and its teapotinfo mirror goes with it.
content is bytes and the wire carries it as base64. Where contentType names a text media
type (text/*, application/json, application/x-sh, application/xml), the CLI shows it
as that text in TOML and encodes it back on the way in - so the two tabs of the example below
differ, and either one applies as it stands. With no contentType, or a binary one, the
payload is base64 in both.
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be teapot. |
id |
string | when enabled | ID is the probe's payload id. |
label |
string | no | Label is a free-form label. |
vessel |
string | no | Which pot this config addresses. Set when the component is created and fixed for its life: pointing the same component at another pot is not an edit, it is a different component. Write-once: fixed when the component is created. |
content |
string (base64) | no | Content is an opaque payload, kept as it is. |
contentType |
string | no | ContentType is the media type of Content. |
enabled |
bool | no | Enabled makes the probe apply; disabled, the config may be an incomplete shell. |
Commands¶
| Command | Request | Description |
|---|---|---|
sys.teapot.<id>.brew |
JSON | Starts a brew on this teapot at the given strength. |
Configuration status¶
| Status | Message (example) | Meaning | Cleared by |
|---|---|---|---|
OK |
- | The configurer holds the config. It never means anything happened | - |
DISABLED |
- | enabled = false. The config is held all the same |
enabling it |
DELETED |
- | The config was deleted | writing it again |
The teapot never reports WAIT or ERROR: it does nothing that can fail. A malformed body, a
schema violation, or a vessel changed after the component was created is refused by the
configurer, so it never reaches the effective stage.
Application status¶
Typed status teapot is registered under this component name.
The teapot runs nothing, so no telemetry is published under cfg.teapot.<id>: the status
type exists to exercise the status registry and schema pipeline, and no module publishes it.
Example¶
teapotinfo¶
Component ref: cfg.teapotinfo.<id>
Read-only. Published by the module; not writable through the config store.
Derived from a teapot config, and produced for the same reason the
teapot is: so the derived-companion path has a subject with nothing behind it.
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be teapotinfo. |
teapotRef |
component ref | no | TeapotComponent is the writable teapot this mirrors. |
id |
string | no | ID mirrors the teapot config's id. |
label |
string | no | Label mirrors the teapot config's label. |
Delete the teapot config and this mirror goes with it.
Application status¶
No typed status is registered under this component name.
A mirror has no runtime.
trigger¶
Component ref: <module>.trigger.<id>
Delete: regular (RFC-023) - the component is destroyed and disappears from config ls after commit.
Evaluates a match condition and runs an action when it fires. See Trigger for the full expression language reference.
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be trigger. |
enabled |
bool | no | Enabled switches the rule on. A disabled config may be an incomplete shell: name, match and action are required only when enabled. |
name |
string | when enabled | Name is the rule's human-readable label. |
match |
object | when enabled | Match is the condition the rule evaluates, a match-kind union discriminated by type; streamExpr is the kind in use. One of streamExpr, teapot, discriminated by type. |
action |
object | when enabled | Action is what the rule does when the match fires, an action-kind union discriminated by type; a composite runs several. One of measurement, command, teapot, ref, event, status, patchConfig, jsonCommand, composite, discriminated by type. |
The match kinds are under Match types and the action kinds under Action types. Property bindings ({ "$value": "name" }, $name) are resolved by the configurer from the resolution envelope the rule is written in, not from a field of the rule.
Example: speed-based measurement trigger¶
{
"type": "trigger",
"enabled": true,
"name": "Slow speed measurement",
"match": {
"type": "streamExpr",
"expr": "data_status_val('gps.channel.direction.speed', 'agg=max') < 10 && data_status_val('gps.channel.direction.speed', 'agg=min') > 2",
"interval": 1
},
"action": {
"type": "measurement",
"endDelay": 30
}
}
Match types¶
streamExpr¶
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be streamExpr. |
expr |
string | yes | Expression is the boolean expression the rule evaluates, in the trigger expression language; a match is an evaluation that yields true. |
interval |
number | no | Interval is the evaluation cadence in seconds. 0 evaluates once per message, against a pool holding only that message; a positive value evaluates every interval against the full pool, and is required (at least 0.2) when the expression reads more than one channel or messaging context. At least 0. |
env |
object | no | Env is the expression variables available to expr by bare name, typed when the expression is compiled: literals, or { "$value": "name" } slots the configurer binds from the property bag before the module compiles the expression - the same convention as the reducerExpr processor's env. |
expr is written in the expression language.
Using env in a streamExpr expression
Define expression variables in match.env, not via top-level trigger properties. Each env key is available in expr by name - e.g. expr: "... > threshold" with env: { "threshold": { "$value": "threshold" } } and a threshold property in the template bag. The configurer resolves property references and binds literals into env before the trigger module compiles the expression. Literal env values are also supported: env: { "limit": 42 }. Do not write $threshold inside an expr to use a property as a value: inline binding freezes the value into the expression text at config-update time. Reserve ${name} inside an expr for the rare case of defining part of the expression text from a property.
teapot¶
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be teapot. |
description |
string | no | Description is free text. |
A probe kind with no condition, there so the match union can be exercised with nothing behind it.
Action types¶
Every action accepts when and unless, which decide whether it runs at all. This is what
lets one rule branch: with two rules the branches are two subscriptions evaluated
independently, so they can observe the pool at different instants and both fire, or neither.
An action carries at most one of the two. The value is a boolean, or a binding slot that
resolves to one: { "$value": "name" } for a config property, resolved when the config is
updated, and { "@value": "name" } for a match capture, resolved when the trigger fires. A
condition that does not resolve to a boolean - a missing capture, a number, a "@armed"
string, which token expansion can only turn into a string - is an action error, counted in
actionFailures. It is never a silent skip: a gate that never opens because of a typo would
otherwise be invisible.
Omitting both runs the action unconditionally, so absence is a third state and not false.
Two things follow from where the condition is evaluated. A capture is only set by the part of
the expression that ran, so a capture() inside a short-circuited && or || never
happens - a condition reading it then fails at fire time even though the rule matched. And a
gate suppresses, it does not authorize: an older module ignores an unknown field, so the
match expression stays the guard for anything dangerous.
[match]
type = "streamExpr"
interval = 1
expr = """
capture("armed", status_val("sys.gpio.main.arm") == 1);
true"""
[action]
type = "composite"
[[action.actions]]
type = "command"
when = { "@value" = "armed" }
cmdRef = "relay.switch.main.on"
[[action.actions]]
type = "command"
unless = { "@value" = "armed" }
cmdRef = "relay.switch.main.off"
One capture decides both branches, which is what keeps them complementary; publishing an
armed and a disarmed capture instead leaves nothing tying the two together. A when on a
composite gates the whole group, so a branch can hold more than one action.
measurement¶
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be measurement. |
when |
any | no | When runs the action only if the condition is true. A JSON boolean, or a {"$value": "name"} / {"@value": "name"} slot resolving to one. Omitted runs the action unconditionally. |
unless |
any | no | Unless runs the action only if the condition is false - the else branch of a when on the same condition. Same shape as when; an action carries at most one of the two. |
startDelay |
number | no | StartDelay is the measurement start relative to the trigger time, in seconds. |
endDelay |
number | no | StopDelay is the measurement end relative to the trigger time, in seconds. |
triggerType |
string | no | TriggerType is what the fire does to the measurement: startExtend (the default) starts one when none is running and otherwise pushes the running one's end out to the new endDelay; start only starts one when none is running; stop pulls the running one's end in to the new endDelay. One of startExtend, stop, start. |
command¶
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be command. |
when |
any | no | When runs the action only if the condition is true. A JSON boolean, or a {"$value": "name"} / {"@value": "name"} slot resolving to one. Omitted runs the action unconditionally. |
unless |
any | no | Unless runs the action only if the condition is false - the else branch of a when on the same condition. Same shape as when; an action carries at most one of the two. |
minWaitPeriod |
number | no | MinWaitPeriod is the minimum time in seconds since the last execution under the same key before this command runs again. Omitted or 0 uses the module default (--action-command-wait-period); -1 disables the wait entirely, and any other negative value is refused. Seconds, non-negative, or -1 to disable the wait. |
minWaitKey |
string | no | MinWaitKey is the key the wait is tracked under; empty tracks by cmdRef. |
cmdRef |
attribute ref | yes | CommandAttribute is the command sent, as an attribute reference moduleId.componentName.componentId.commandName. |
cmdPayload |
string (base64) | no | CommandPayload is the payload bytes, base64 on the wire. |
cmdPayloadType |
string | no | CommandPayloadType is the payload's MIME type ("text/plain"); empty is application/octet-stream. For a command this build declares (RFC-040) it has to be the type the command takes - a JSON command needs application/json, and is better served by a jsonCommand action, which also checks the body - and a signed command is refused. |
ref¶
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be ref. |
when |
any | no | When runs the action only if the condition is true. A JSON boolean, or a {"$value": "name"} / {"@value": "name"} slot resolving to one. Omitted runs the action unconditionally. |
unless |
any | no | Unless runs the action only if the condition is false - the else branch of a when on the same condition. Same shape as when; an action carries at most one of the two. |
actionRef |
component ref | yes | Component is the trigger component whose action is run. |
event¶
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be event. |
when |
any | no | When runs the action only if the condition is true. A JSON boolean, or a {"$value": "name"} / {"@value": "name"} slot resolving to one. Omitted runs the action unconditionally. |
unless |
any | no | Unless runs the action only if the condition is false - the else branch of a when on the same condition. Same shape as when; an action carries at most one of the two. |
eventName |
string | yes | EventName is a registered event type name, or custom_<label> for an event you define yourself - the label is yours and required, custom alone is not a name. Any other name is refused at config time. |
fields |
any | yes | Fields is the event payload, a JSON object with at least one field; its keys are the published field names. A { "$value": "name" } slot or an inline $name token binds a config property when the config is updated, a { "@value": "name" } slot or an inline @name token binds a match capture when the trigger fires. For a registered eventName the keys, requiredness and literal value types are checked against the event's schema at config time; a @name token in a field that takes anything but a string is refused then, and a slot whose capture the field cannot hold is refused when the trigger fires. A custom_<label> event's payload is flat, with JSON scalar values. |
fieldTypes |
object | no | FieldTypes declares the storage type of individual fields - string, int, uint, float or bool - for a custom_<label> event only: a field with no entry keeps whatever its literal says, and an undeclared number is a float. A registered event's types come from its schema, and declaring them here is refused. Values: string. |
status¶
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be status. |
when |
any | no | When runs the action only if the condition is true. A JSON boolean, or a {"$value": "name"} / {"@value": "name"} slot resolving to one. Omitted runs the action unconditionally. |
unless |
any | no | Unless runs the action only if the condition is false - the else branch of a when on the same condition. Same shape as when; an action carries at most one of the two. |
name |
string | yes | Name is the component name the state is published under; the component is <moduleId>.<name>.<triggerId>. A registered status name is refused. |
fields |
any | yes | Fields is the state, a JSON object with at least one field; its keys are the published field names, and on a match the bound map replaces the whole snapshot. Slots and tokens bind as in an event action: $ from config properties when the config is updated, @ from match captures when the trigger fires. |
patchConfig¶
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be patchConfig. |
when |
any | no | When runs the action only if the condition is true. A JSON boolean, or a {"$value": "name"} / {"@value": "name"} slot resolving to one. Omitted runs the action unconditionally. |
unless |
any | no | Unless runs the action only if the condition is false - the else branch of a when on the same condition. Same shape as when; an action carries at most one of the two. |
configRef |
component ref | yes | ConfigRef is the config component to patch. |
enabled |
bool | no | Enabled sets the target's enabled state; omitted leaves it as it is. |
properties |
any | no | Properties is the delta property list, as JSON: the entries to set, by name. A { "$value": "name" } slot or an inline $name token binds a config property when the config is updated, a { "@value": "name" } slot or an inline @name token binds a match capture when the trigger fires. An int or float entry refuses a token in its value at config time, and a capture it cannot hold when the trigger fires. |
force |
bool | no | Force applies the patch even when it changes no value. |
minWaitPeriod |
number | no | MinWaitPeriod is the minimum time in seconds since the last execution under the same key before this patch runs again. Omitted or 0 applies no throttle beyond same-instant deduplication; -1 disables the wait entirely, and any other negative value is refused. Seconds, non-negative, or -1 to disable the wait. |
minWaitKey |
string | no | MinWaitKey is the key the wait is tracked under; empty tracks by configRef. |
jsonCommand¶
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be jsonCommand. |
when |
any | no | When runs the action only if the condition is true. A JSON boolean, or a {"$value": "name"} / {"@value": "name"} slot resolving to one. Omitted runs the action unconditionally. |
unless |
any | no | Unless runs the action only if the condition is false - the else branch of a when on the same condition. Same shape as when; an action carries at most one of the two. |
minWaitPeriod |
number | no | MinWaitPeriod is the minimum time in seconds since the last execution under the same key before this command runs again. Omitted or 0 uses the module default (--action-command-wait-period); -1 disables the wait entirely, and any other negative value is refused. Seconds, non-negative, or -1 to disable the wait. |
minWaitKey |
string | no | MinWaitKey is the key the wait is tracked under; empty tracks by cmdRef. |
cmdRef |
attribute ref | yes | CommandAttribute is the command sent, as an attribute reference moduleId.componentName.componentId.commandName. |
cmdPayload |
any | no | CommandPayload is the JSON object sent as the command body once its slots and tokens are bound: $ from config properties when the config is updated, @ from match captures when the trigger fires. For a command this build declares (RFC-040) the payload is checked against the declaration when the config is applied - the keys, the required fields, the kind of every literal, and a @name token in a field that takes anything but text - and a captured value the field cannot hold is refused when the trigger fires; a signed command, and a command that takes text, are refused outright. A command the build does not declare is sent as it is. |
composite¶
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be composite. |
when |
any | no | When runs the action only if the condition is true. A JSON boolean, or a {"$value": "name"} / {"@value": "name"} slot resolving to one. Omitted runs the action unconditionally. |
unless |
any | no | Unless runs the action only if the condition is false - the else branch of a when on the same condition. Same shape as when; an action carries at most one of the two. |
actions |
array | no | Actions are the members, run serially in order. Each entry is one of measurement, command, teapot, ref, event, status, patchConfig, jsonCommand, composite, discriminated by type. |
ignoreErrors |
bool | no | IgnoreErrors logs a failing member and continues; otherwise the first error stops the composite. A member skipped by its when or unless is not an error either way. |
A member skipped by its when or unless is not an error, so it neither stops a composite
that halts on the first error nor is counted by ignoreErrors.
teapot¶
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be teapot. |
when |
any | no | When runs the action only if the condition is true. A JSON boolean, or a {"$value": "name"} / {"@value": "name"} slot resolving to one. Omitted runs the action unconditionally. |
unless |
any | no | Unless runs the action only if the condition is false - the else branch of a when on the same condition. Same shape as when; an action carries at most one of the two. |
action |
string | no | Action is free text the probe logs with each fire it reports. |
waitCounter |
int | no | WaitCounter reports only every n-th fire; 0 reports every one. |
A probe kind that logs its fires and does nothing else, there so the action union can be exercised with nothing behind it.
Application status¶
Typed status trigger is registered under this component name.
Published on <module>.trigger.<id> on every status tick: the rule's health (OK running, DISABLED, WAIT parked on property resolution, ERROR failed to build) and its counters - matches, matchingFailures, actionsRun, actionsSkipped, actionFailures. A status action publishes a component of its own, <module>.<name>.<id>, alongside.
Example: command action¶
webuser¶
Component ref: webui.webuser.<name>
Delete: regular (RFC-023) - the component is destroyed and disappears from config ls after commit.
The component id is the username. One user of the local web UI (RFC-008). The password never travels the configuration API: passwordRef names a secret of kind bcrypt (declared with consumers = ["webui.webuser"]) whose value is the password's bcrypt hash - written with algol secret set --bcrypt, verified by webui at login - so config info, manifests and bundles show the reference and nothing else. webuser is a privileged type (RFC-035 D8): no configured role can be granted write on it; only the built-in roles write it.
On delete the user is gone and their open sessions end.
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be webuser. |
enabled |
bool | no | Enable the user. A disabled user cannot log in, and disabling ends the user's open sessions. |
name |
string | yes | The person this user is, as the web UI names them (e.g. "Lukas Zaugg"). Free text, never empty, and not the username - that is the component id. At least 1 character. |
role |
string | when enabled | The user's role, by name (e.g. "keymaster"): what the user may see and do. Which names are in service is decided when the config is applied, not by this document. |
passwordRef |
component ref | when enabled | The user's password, as a reference to a secret of kind bcrypt ("cfg.secret.<id>") whose value is the password's bcrypt hash. Required when enabled. |
description |
string | no | Free text for config ls and the web UI. |
role names a built-in role (keymaster, superuser) or a role config; webui mirrors no role definitions, so a name that is undefined or disabled applies OK and its holder is refused when their connection is opened at login (RFC-035 D6). A disabled config may leave role and passwordRef out; a passwordRef that is set must name a secret component even then. name is required whatever the user's state - a disabled user is still somebody, and the surfaces that list one have to print something.
Configs written before name existed are migrated at load: the step writes the username into it (RFC-009 chain, webuser v0 -> v1), which is what those surfaces showed before the field existed.
Configuration status¶
The effective stage, reported by webui. In every WAIT row the user cannot log in, and a session already open is closed by the same apply - as it is when the user's role changes, since what the web UI draws for them was read at the login they are still inside.
The role name is not a row: whether a role is in service is settled when the person's bus connection is opened, so a user whose role is undefined or disabled is OK here and cannot log in.
| Status | Message (example) | Meaning | Cleared by |
|---|---|---|---|
OK |
The user can log in with the stored password | - | |
DISABLED |
enabled = false: the user is kept but cannot log in |
enabling | |
WAIT |
secret cfg.secret.webui_lukas: not declared |
passwordRef names a secret that does not exist |
declaring and setting it (the re-dispatch re-applies) |
WAIT |
secret cfg.secret.webui_lukas: no value |
declared, no password set yet | algol secret set --bcrypt |
WAIT |
secret cfg.secret.webui_lukas: unavailable |
the configurer did not answer | the configurer coming back (its startup republish re-applies a config resting in WAIT) |
ERROR |
secret cfg.secret.webui_lukas is a string secret, needs bcrypt |
wrong kind; the user cannot log in | a secret of kind bcrypt |
ERROR |
secret cfg.secret.webui_lukas: not admitted |
the declaration does not list webui.webuser - normally refused at ingest; reaches webui only if the two got out of step |
the declaration admitting webui.webuser, or the reference removed |
The configurer refuses, before the update reaches webui: a missing role or passwordRef on an enabled config, a role that is not a single name, and a passwordRef that does not name a secret component or names one whose declaration does not admit webui.webuser.
Application status¶
No typed status is registered under this component name.
Who is logged in is not telemetry; a login is an event if it is anything.
Example¶
webuserinfo¶
Component ref: webui.webuserinfo.<name>
Read-only. Published by the module; not writable through the config store.
The component id is the username, the same one its webuser carries. The public half of the key webui generated for one web user, published by webui (RFC-035 D6).
It is the second half of a web user's enrolment. Enrolling a user key in the registry needs a keymaster's signature and webui is not a keymaster, so a web user's key is not in the registry file. Instead a keymaster's webuser write says who the person is and which role they hold, and this config is webui stating which key it generated for them. The nats app mirrors both and admits that key as a user key - the role from the webuser, the label from userRef - so a request from the browser is decided like any other person's, and nothing downstream of the bus knows a browser is behind it.
The seed never leaves webui's own volume: the holder generates its own key (RFC-034 D1), and here the holder is webui, the way a browser holds a client certificate. Nothing secret is published - the public half is what admits a connection, never what opens one.
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Must be webuserinfo. |
key |
string | yes | The public NKey (U...) webui generated for the user, and the key their connection is admitted under. |
userRef |
component ref | yes | The web user this key belongs to ("webui.webuser.<name>"). It is the label the user-key entry carries, so an audit line names the person rather than their key. |
Application status¶
No typed status is registered under this component name.
A key record has no runtime.
algol config ls 'webui.webuserinfo.*' # the key held for each web user
algol config info webui.webuserinfo.vbb
There is no write path and no delete: the record goes when the user does, which a webuser delete decides. Disabling or deleting the user withdraws the key's admission, and the bus ends the connections it admitted.