Skip to content

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

  1. Update - send a config payload to the configurer. The running config is updated in memory immediately.
  2. Commit - persist all valid running configs to disk so they survive a reboot.
  3. 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).

# gsm.toml
type = "gsm"
enabled = true
apn = "internet"
algol config update sys.gsm.gsm0 < gsm.toml
algol config commit sys.gsm.gsm0
algol config update sys.gsm.gsm0 --format json '{
  "type": "gsm",
  "enabled": true,
  "apn": "internet"
}'

algol config commit sys.gsm.gsm0

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

type = "props"

[[properties]]
type = "string"
name = "device_label"
value = "RET-42"

[[properties]]
type = "configRef"
name = "tenant_id"
ref = "cfg.propsinfo.system"
jsonPath = "$.tenant_id"
timeout = -1
{
  "type": "props",
  "properties": [
    { "type": "string", "name": "device_label", "value": "RET-42" },
    {
      "type": "configRef",
      "name": "tenant_id",
      "ref": "cfg.propsinfo.system",
      "jsonPath": "$.tenant_id",
      "timeout": -1
    }
  ]
}
algol config update cfg.props.shared < props.toml
algol config commit cfg.props.shared

To change a value later, patch by name. The type is the one already on the property.

algol config patch cfg.props.shared -p device_label=RET-99

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 routeMetric deletes 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. A gsm config that declares no ipv4 object 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 sys container (--dnsmasq-shared-dir), which the shipped per-target deployments do. Where it is not mounted, none is ERROR on 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 file and systemdscript. A LAN whose DHCP somebody else switched off reports none.
  • 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.&lt;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 &lt;bundle>@&lt;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):

type = "bridge"
enabled = true

[ipv4]
  method = "auto"
  routeMetric = 100
{
  "type": "bridge",
  "enabled": true,
  "ipv4": {
    "method": "auto",
    "routeMetric": 100
  }
}
algol config update sys.bridge.br0 < bridge.toml
algol config commit sys.bridge.br0

LAN. shared is opt-in: a shared bridge on a foreign segment is a rogue DHCP server.

type = "bridge"
enabled = true

[ipv4]
method = "shared"
addresses = ["192.168.10.1/24"]
{
  "type": "bridge",
  "enabled": true,
  "ipv4": {
    "method": "shared",
    "addresses": ["192.168.10.1/24"]
  }
}

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
    }
  ]
}
algol config update bc.channel.axl < channel.toml
algol config commit bc.channel.axl

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.

algol config info gps.channelinfo.position

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

algol config info sys.deviceinfo.main

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.

algol data-source ls
algol data-source info gps.dsource.gps

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

type = "dns"
servers = ["8.8.8.8", "8.8.4.4"]
{
  "type": "dns",
  "servers": ["8.8.8.8", "8.8.4.4"]
}
algol config update sys.dns.main < dns.toml
algol config commit sys.dns.main

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:

type = "ethernet"
bridgeRef = "sys.bridge.br0"
enabled = true
{
  "type": "ethernet",
  "enabled": true,
  "bridgeRef": "sys.bridge.br0"
}
algol config update sys.ethernet.eth0 < ethernet.toml
algol config commit sys.ethernet.eth0

Standalone uplink (DHCP client):

type = "ethernet"
enabled = true

[ipv4]
method = "auto"
{
  "type": "ethernet",
  "enabled": true,
  "ipv4": { "method": "auto" }
}

Standalone static IP:

type = "ethernet"
enabled = true

[ipv4]
method = "manual"
addresses = ["192.168.1.100/24"]

[[ipv4.routes]]
network = "0.0.0.0/0"
gateway = "192.168.1.1"
{
  "type": "ethernet",
  "enabled": true,
  "ipv4": {
    "method": "manual",
    "addresses": ["192.168.1.100/24"],
    "routes": [{ "network": "0.0.0.0/0", "gateway": "192.168.1.1" }]
  }
}

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 ssid is what the link is on, which is not the configured ssid while a changed profile has not been applied yet. It is absent on a hidden network, where the access point answers with an empty one; bssid is what identifies the link there.
  • bssid is the answer to "which access point", where several share one ssid. A device that roamed and one that did not look the same without it.
  • signalStrength is a percentage as NetworkManager reports it, not dBm and not convertible to one. 0 is 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. bitrate comes 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):

type = "wlan"
enabled = true
mode = "client"
pskRef = "cfg.secret.wifi_site"
security = "wpaPsk"
ssid = "site-uplink"

[ipv4]
  method = "auto"
{
  "type": "wlan",
  "enabled": true,
  "mode": "client",
  "ssid": "site-uplink",
  "security": "wpaPsk",
  "pskRef": "cfg.secret.wifi_site",
  "ipv4": {
    "method": "auto"
  }
}
algol config update sys.wlan.wlan0 < wlan.toml
algol config commit sys.wlan.wlan0

Access point, its own LAN. shared is opt-in, as on bridge:

type = "wlan"
enabled = true
mode = "ap"
ssid = "device-lan"
security = "wpaPsk"
pskRef = "cfg.secret.wifi_site"

[ipv4]
method = "shared"
addresses = ["192.168.20.1/24"]
{
  "type": "wlan",
  "enabled": true,
  "mode": "ap",
  "ssid": "device-lan",
  "security": "wpaPsk",
  "pskRef": "cfg.secret.wifi_site",
  "ipv4": {
    "method": "shared",
    "addresses": ["192.168.20.1/24"]
  }
}

Access point bridged into br0, one segment with the Ethernet ports:

type = "wlan"
enabled = true
mode = "ap"
ssid = "device-lan"
security = "open"
bridgeRef = "sys.bridge.br0"
{
  "type": "wlan",
  "enabled": true,
  "mode": "ap",
  "ssid": "device-lan",
  "security": "open",
  "bridgeRef": "sys.bridge.br0"
}

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

type = "dummyintf"
enabled = true

[ipv4]
  addresses = ["10.255.0.1/32"]
  method = "manual"
{
  "type": "dummyintf",
  "enabled": true,
  "ipv4": {
    "method": "manual",
    "addresses": ["10.255.0.1/32"]
  }
}
algol config update sys.dummyintf.dummy0 < dummyintf.toml
algol config commit sys.dummyintf.dummy0

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 a cachePriority above none is 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 a digest (see below). fileinfo.filename points 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 configured digest - 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 is WAIT on both the file and its fileinfo, so a systemdscript or systemdapp sourcing fileinfo.filename through a configRef is 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; maxDownloadAttempts bounds the attempts (0 or 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 changed url restarts. 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

type = "file"
description = "Hello World inline file"
digest = "md5:5eb63bbbe01eeed093cb22bb8f5acdc3"
enabled = true
url = "data:text/plain;base64,SGVsbG8gV29ybGQ="
{
  "type": "file",
  "url": "data:text/plain;base64,SGVsbG8gV29ybGQ=",
  "digest": "md5:5eb63bbbe01eeed093cb22bb8f5acdc3",
  "enabled": true,
  "description": "Hello World inline file"
}
algol config update sys.file.hello < file.toml
algol config commit sys.file.hello

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.

algol config info sys.fileinfo.hello

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

type = "flow"
defaultChannelRefs = ["bc.channel.axl", "bc.channel.gyro", "gps.channel.pos"]
{
  "type": "flow",
  "defaultChannelRefs": ["bc.channel.axl", "bc.channel.gyro", "gps.channel.pos"]
}
algol config update flower.flow.main < flow.toml
algol config commit flower.flow.main

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

type = "gate"
deviceId = "rar4000"
enabled = true
remoteNatsCredsRef = "cfg.secret.gate_parametric_rar4000"
remoteNatsUrl = "tls://connect.ngs.global"
statusForwardFilter = ["sys.*.*", "recorder.*.*"]
statusForwardInterval = 30
tenantId = "parametric"
{
  "type": "gate",
  "statusForwardFilter": ["sys.*.*", "recorder.*.*"],
  "statusForwardInterval": 30,
  "enabled": true,
  "remoteNatsUrl": "tls://connect.ngs.global",
  "remoteNatsCredsRef": "cfg.secret.gate_parametric_rar4000",
  "deviceId": "rar4000",
  "tenantId": "parametric"
}
algol config update gate.gate.main < gate.toml
algol config commit gate.gate.main

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:

algol secret set gate_parametric_rar4000 --kind bytes --consumer gate.gate --from-file ngs.creds
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" }
        ]
      }
    ]
  }
}
algol config update gps.channel.position < position.toml
algol config commit gps.channel.position

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

type = "gpsapp"
baudRate = 115200
enabled = true
frequency = 5
{
  "type": "gpsapp",
  "enabled": true,
  "baudRate": 115200,
  "frequency": 5
}
algol config update gps.gpsapp.main < gpsapp.toml
algol config commit gps.gpsapp.main

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

type = "gsm"
apn = "internet"
enabled = true

[ipv4]
  method = "auto"
  routeMetric = 900
{
  "type": "gsm",
  "enabled": true,
  "apn": "internet",
  "ipv4": {
    "method": "auto",
    "routeMetric": 900
  }
}
algol config update sys.gsm.gsm0 < gsm.toml
algol config commit sys.gsm.gsm0

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:

POST http://<device>/hooks/webui.httpendpoint.doorpanel/door/3

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

type = "httpendpoint"
enabled = true
mode = "async"

[auth]
  secretRef = "cfg.secret.doorpanel_token"
  type = "bearer"
{
  "type": "httpendpoint",
  "enabled": true,
  "mode": "async",
  "auth": {
    "type": "bearer",
    "secretRef": "cfg.secret.doorpanel_token"
  }
}
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

type = "kubepod"
[[containers]]
  image = "registry.example.com/my-workload:v1.2.3"
  name = "my-workload"

  [[containers.env]]
    name = "TARGET_URL"
    value = "http://host:8080"

  [[containers.volumeMounts]]
    mountPath = "/media"
    name = "media-volume"
{
  "type": "kubepod",
  "containers": [
    {
      "env": [
        {
          "name": "TARGET_URL",
          "value": "http://host:8080"
        }
      ],
      "image": "registry.example.com/my-workload:v1.2.3",
      "name": "my-workload",
      "volumeMounts": [
        {
          "mountPath": "/media",
          "name": "media-volume"
        }
      ]
    }
  ]
}
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

algol command sys.led.status.set '{"green":"blink","red":"on"}'

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"
  }
}
algol config update sys.led.status < led.toml
algol config commit sys.led.status

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

type = "netprobe"
description = "LTE path reachability"
enabled = true
interface = "wwan0"
interval = 30
name = "LTE uplink"
timeout = 3

[probe]
  target = "8.8.8.8"
  type = "icmp"
{
  "type": "netprobe",
  "enabled": true,
  "name": "LTE uplink",
  "description": "LTE path reachability",
  "interface": "wwan0",
  "interval": 30,
  "timeout": 3,
  "probe": {
    "type": "icmp",
    "target": "8.8.8.8"
  }
}
algol config update sys.netprobe.lte0 < netprobe.toml
algol config commit sys.netprobe.lte0

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

type = "loradevice"
applicationKey = "0102030405060708090a0b0c0d0e0f10"
deviceProfile = "OTAA-EU868"
enabled = true
name = "Sensor Node 1"

[tags]
  location = "roof"
{
  "type": "loradevice",
  "name": "Sensor Node 1",
  "deviceProfile": "OTAA-EU868",
  "applicationKey": "0102030405060708090a0b0c0d0e0f10",
  "tags": {
    "location": "roof"
  },
  "enabled": true
}
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

type = "loragateway"
name = "Gateway Rooftop"
{
  "type": "loragateway",
  "name": "Gateway Rooftop"
}
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

type = "ntp"
[[pools]]
  pool = "pool.ntp.org"

[[servers]]
  server = "192.168.1.1"
{
  "type": "ntp",
  "servers": [
    {
      "server": "192.168.1.1"
    }
  ],
  "pools": [
    {
      "pool": "pool.ntp.org"
    }
  ]
}
algol config update chrony.ntp.main < ntp.toml
algol config commit chrony.ntp.main

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.

algol config info chrony.ntppps.main

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.

algol config info cfg.propsinfo.system

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:

type = "file"
enabled = true
url = "data:text/plain;base64,SGVsbG8="

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

type = "recstorage"
enabled = true
expectedMediumId = "ALGOL-REC"
maxRecordingUsage = 80
recordingDir = "/mnt/disk00"
{
  "type": "recstorage",
  "enabled": true,
  "recordingDir": "/mnt/disk00",
  "expectedMediumId": "ALGOL-REC",
  "maxRecordingUsage": 80
}
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.&lt;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
}
algol config update cfg.role.technician < role.toml
algol config commit cfg.role.technician

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:

[action]
type = "command"
cmdRef = "recorder.s3upload.cloud.start"

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

type = "s3upload"
autoStart = true
bucket = "my-algol-recordings"
credentialsRef = "cfg.secret.aws_export"
enabled = true
keyPrefix = "rar4000/recordings"
region = "eu-central-1"
{
  "type": "s3upload",
  "enabled": true,
  "autoStart": true,
  "keyPrefix": "rar4000/recordings",
  "bucket": "my-algol-recordings",
  "region": "eu-central-1",
  "credentialsRef": "cfg.secret.aws_export"
}
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"
{
  "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>) - exportDir resolved outside every allowlisted root.
  • export directory is empty, or cannot resolve export directory <dir>: <detail>.
  • export directory <dir> is already used by export profile <ref>, or destination 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 at algol config update. The command fails, nothing is published, and the profile keeps the effective status it already had.
  • An unresolved binding - exportDir and destinationId come from sys.systemdmountinfo.<slot> - is reported on the intent status stage as WAIT. 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

type = "usbexport"
destinationId = "by-label/ALGOL01"
enabled = true
exportDir = "/mnt/disk01"
pathPrefix = "algol/rar4000"
{
  "type": "usbexport",
  "enabled": true,
  "exportDir": "/mnt/disk01",
  "destinationId": "by-label/ALGOL01",
  "pathPrefix": "algol/rar4000"
}
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

type = "secret"
consumers = ["sys.wlan"]
description = "WPA2 key of the site uplink"
kind = "string"
{
  "type": "secret",
  "kind": "string",
  "consumers": ["sys.wlan"],
  "description": "WPA2 key of the site uplink"
}
algol config update cfg.secret.wifi_site < secret.toml
algol config commit cfg.secret.wifi_site

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

type = "secomea"
agents = ["agent=ZZ:WPC name=\"Full Access\" sn=\"#A1\" cfg=\"PC\""]
domain = "my_domain"
enabled = true
name = "MY_DEVICE"
servers = ["satomec.secomea.com"]
{
  "type": "secomea",
  "enabled": true,
  "domain": "my_domain",
  "servers": ["satomec.secomea.com"],
  "name": "MY_DEVICE",
  "agents": ["agent=ZZ:WPC name=\"Full Access\" sn=\"#A1\" cfg=\"PC\""]
}
algol config update secomea.secomea.main < secomea.toml
algol config commit secomea.secomea.main

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

type = "systemdapp"
args = ["--verbose"]
enabled = true
env = ["LOG_LEVEL=info"]
exec = "/usr/bin/myapp"
rootfs = "/var/lib/algol/apps/myapp.squashfs"
user = "nobody"
{
  "type": "systemdapp",
  "enabled": true,
  "rootfs": "/var/lib/algol/apps/myapp.squashfs",
  "exec": "/usr/bin/myapp",
  "args": ["--verbose"],
  "env": ["LOG_LEVEL=info"],
  "user": "nobody"
}
algol config update sys.systemdapp.myapp < systemdapp.toml
algol config commit sys.systemdapp.myapp

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.

type = "resolution"
targetConfigType = "systemdapp"
enabled = true
rootfs = "$rootfsRef"
exec = "/usr/bin/myapp"
user = "nobody"

[[properties]]
type = "configRef"
name = "rootfsRef"
ref = "sys.fileinfo.myapp_rootfs"
jsonPath = "$.filename"
timeout = -1
{
  "type": "resolution",
  "targetConfigType": "systemdapp",
  "enabled": true,
  "rootfs": "$rootfsRef",
  "exec": "/usr/bin/myapp",
  "user": "nobody",
  "properties": [
    {
      "type": "configRef",
      "name": "rootfsRef",
      "ref": "sys.fileinfo.myapp_rootfs",
      "jsonPath": "$.filename",
      "timeout": -1
    }
  ]
}

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

type = "systemdmount"
diskSelector = "by-label/ALGOL01"
enabled = true
options = "noatime,uid=1000,gid=1000"
requireFsType = "exfat"
{
  "type": "systemdmount",
  "enabled": true,
  "diskSelector": "by-label/ALGOL01",
  "options": "noatime,uid=1000,gid=1000",
  "requireFsType": "exfat"
}
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.

algol config info sys.systemdmountinfo.disk01

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

type = "systemdscript"
args = ["--verbose"]
enabled = true
env = ["MY_VAR=value"]
filename = "/var/lib/algol/scripts/setup.sh"
interpreter = "bash"
{
  "type": "systemdscript",
  "filename": "/var/lib/algol/scripts/setup.sh",
  "args": ["--verbose"],
  "interpreter": "bash",
  "env": ["MY_VAR=value"],
  "enabled": true
}
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
{
  "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

type = "systemdunit"
enabled = true
unit = "tailscaled.service"
{
  "type": "systemdunit",
  "enabled": true,
  "unit": "tailscaled.service"
}
algol config update sys.systemdunit.tailscale < systemdunit.toml
algol config commit sys.systemdunit.tailscale
algol command sys.systemdunit.tailscale.start
algol command sys.systemdunit.tailscale.restart

A role that may operate it, and nothing else:

{
  "type": "role",
  "enabled": true,
  "read": ["sys.*.*"],
  "exec": ["sys.systemdunit.tailscale.*"]
}

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

type = "teapot"
content = "one spoon per cup, 95 C, 4 minutes"
contentType = "text/plain"
enabled = true
id = "pot0"
label = "Earl Grey"
vessel = "pot"
{
  "type": "teapot",
  "id": "pot0",
  "label": "Earl Grey",
  "vessel": "pot",
  "content": "b25lIHNwb29uIHBlciBjdXAsIDk1IEMsIDQgbWludXRlcw==",
  "contentType": "text/plain",
  "enabled": true
}
algol config update cfg.teapot.pot0 < teapot.toml
algol config commit cfg.teapot.pot0

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.

algol config info cfg.teapotinfo.pot0

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"

[action]
  endDelay = 30
  type = "measurement"

[match]
  expr = "data_status_val('gps.channel.direction.speed', 'agg=max') < 10 && data_status_val('gps.channel.direction.speed', 'agg=min') > 2"
  interval = 1
  type = "streamExpr"
{
  "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
  }
}
algol config update trigger.trigger.speed < trigger.toml
algol config commit trigger.trigger.speed

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

type = "trigger"
enabled = true
name = "Toggle relay"

[match]
type = "streamExpr"
expr = "status_val('sensor.button.main.pressed') == true"

[action]
type = "command"
cmdRef = "relay.switch.main.toggle"
cmdPayload = "MQ=="
minWaitPeriod = 60
{
  "type": "trigger",
  "enabled": true,
  "name": "Toggle relay",
  "match": {
    "type": "streamExpr",
    "expr": "status_val('sensor.button.main.pressed') == true"
  },
  "action": {
    "type": "command",
    "cmdRef": "relay.switch.main.toggle",
    "cmdPayload": "MQ==",
    "minWaitPeriod": 60
  }
}

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

type = "webuser"
description = "Lukas (commissioning)"
enabled = true
name = "Lukas Zaugg"
passwordRef = "cfg.secret.webui_lukas"
role = "keymaster"
{
  "type": "webuser",
  "enabled": true,
  "name": "Lukas Zaugg",
  "role": "keymaster",
  "passwordRef": "cfg.secret.webui_lukas",
  "description": "Lukas (commissioning)"
}
algol config update webui.webuser.lukas < webuser.toml
algol config commit webui.webuser.lukas

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.

Example

{
  "type": "webuserinfo",
  "key": "UWEBQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCD",
  "userRef": "webui.webuser.vbb"
}