Make the docs state things rather than argue them
Docs / docs (push) Successful in 37s
Playwright Tests / test-playwright (1, 2) (push) Failing after 1m35s
Playwright Tests / test-playwright (2, 2) (push) Failing after 17s
pre-commit / pre-commit (push) Failing after 2m8s
Test Backend / test-backend (push) Failing after 2m48s
Compose Smoke Test / test-compose (push) Failing after 13s
Playwright Tests / merge-reports (push) Failing after 2m25s

The site read as a design journal: rationale paragraphs, hedges
("deliberately", "on purpose", "genuinely"), meta-commentary about the docs
themselves, and one em-dash every ten lines carrying an aside.

Roughly twenty rationale blocks are gone or reduced to what a reader needs
in order to use the thing. Em-dashes go from 507 to 135, and what is left is
structural rather than prose: list and definition separators, table cells,
and four inside code blocks that quote what the CLI actually prints.

Also: api.example.com becomes api.fluksio.com (the emails stay, since
bootstrap.py really defaults to admin@example.com and RFC 2606 reserves it);
the mqtt table gains the two settings it had drifted behind on and inject's
wording matches the engine; llms.txt lists the two connector pages that were
in the nav but not in it; and the two device/device_policy notes now agree.

Builds clean under `zensical build --strict`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YrQnKV3bnQd4K342y8tKj
This commit is contained in:
2026-08-31 10:49:58 +02:00
co-authored by Claude Opus 5
parent 2422a9b22b
commit bdad6d7fc2
25 changed files with 450 additions and 479 deletions
+10 -10
View File
@@ -17,7 +17,7 @@ Anything already exported wins over the file.
| `DATA_DIR` | `flow-data` (`./.fluksio` via the CLI; `~/.fluksio` with `--global`) | everything below derives from this |
| `DATABASE_URL` | SQLite in `DATA_DIR` | any SQLAlchemy URL |
| `FLOWS_DIR` | `$DATA_DIR/flows` | the git repository holding flows |
| `SECRETS_FILE` | `$DATA_DIR/secrets.enc` | encrypted credentials, deliberately outside the repo |
| `SECRETS_FILE` | `$DATA_DIR/secrets.enc` | encrypted credentials, kept outside the repo |
| `ALERTS_FILE` | `$DATA_DIR/alerts.json` | channels and rules |
| `PROVISIONERS_FILE` | `$DATA_DIR/provisioners.json` | clusters a machine can be started from; absent means none |
| `PANELS_FILE` | `$DATA_DIR/panels.json` | wall-panel pairings |
@@ -25,7 +25,7 @@ Anything already exported wins over the file.
| `CLOUD_CONFIG_FILE` | `$DATA_DIR/cloud.json` | the portal enrolment, if any |
| `NODE_VENV` | `auto` | which interpreter node code runs on — see below |
Set `DATA_DIR` and the rest follow. Set one explicitly and it wins which is
Set `DATA_DIR` and the rest follow. Set one explicitly and it wins, which is
what the container images do to pin everything onto `/data`.
`NODE_VENV` is the exception, being about an environment rather than a path:
@@ -43,7 +43,7 @@ An instance that already has a managed venv keeps it on upgrade under
`secrets.enc`, `alerts.json`, `panels.json` and `oauth-key.pem` are written
at runtime. In a container, anything not on a volume lands in the writable
layer and is lost on the next rebuild un-pairing every screen and
layer and is lost on the next rebuild, un-pairing every screen and
revoking every agent.
## State
@@ -54,8 +54,7 @@ An instance that already has a managed venv keeps it on upgrade under
| `REDIS_PORT` | `6379` | |
Flow state is the last value of every message, node memory, and the run queue.
Redis here is persistence, not a cache — run it with append-only persistence
on.
Redis here is persistence, not a cache. Run it with append-only persistence on.
## Identity and access
@@ -85,7 +84,7 @@ there to satisfy.
Empty is the useful value: the interface then addresses the API relative to
whichever origin served the page, so one image answers on a hostname, on a
`http://<host-ip>:<port>`, and through an ssh tunnel alike and no origin has
`http://<host-ip>:<port>`, and through an ssh tunnel alike, and no origin has
to be added to `BACKEND_CORS_ORIGINS`, because there is only one.
`docker/compose.lan.yml` builds it that way and puts an `/api` proxy in front
of the backend to complete it; see
@@ -115,7 +114,7 @@ o'clock nor the same hour in summer as in winter. Set it to where the
instance is.
`production` closes `/docs`, `/redoc` and the OpenAPI document, because the
schema enumerates every endpoint the instance serves including the paths
schema enumerates every endpoint the instance serves, including the paths
webhook nodes mounted at runtime. It also turns a `changethis` secret from a
warning into a refusal to start.
@@ -133,8 +132,9 @@ warning into a refusal to start.
| `ARTIFACT_GC_INTERVAL_S` | `3600` | how often artifact bytes nothing refers to are swept away; 0 never sweeps |
| `ARTIFACT_GC_GRACE_S` | `3600` | how long a freshly written artifact is spared, whatever refers to it |
The three concurrency limits are also flags on `fluksio serve``--max-workers`,
`--max-cascades`, `--max-runs` as is the card count, `--gpus`. The flags
The three concurrency limits are also flags on `fluksio serve`
(`--max-workers`, `--max-cascades`, `--max-runs`), as is the card count,
`--gpus`. The flags
outrank the file, and the engine says which numbers it started with in its
first lines. Each pool size must be at least 1 and the card count at least 0:
a number below that is refused as a flag error naming it, rather than read as
@@ -153,7 +153,7 @@ several at once cannot each size themselves to the whole machine. Setting
that default.
These two are this machine's figures. An attached worker reports its own when
it dials in, and a node goes to whichever machine can grant what it asked for
it dials in, and a node goes to whichever machine can grant what it asked for,
so a GPU on a worker needs no `FLOW_GPUS` here. What every machine has free, and
which nodes are queued, is `GET /api/v1/workers/resources` and the Workers
screen. Named sizes live in the database and are `GET /api/v1/flavors`;
+10 -10
View File
@@ -1,7 +1,7 @@
# The connector contract
A connector is the device-facing node class. It talks to something outside the
engine a device, a service, a protocol and publishes what it finds as
engine (a device, a service, a protocol) and publishes what it finds as
ordinary typed messages, so the rest of a flow cannot tell the difference
between a reading from a heat pump and one from a `print` statement.
@@ -75,7 +75,7 @@ class Params(ConnectorNode.Params):
```
- Booleans render as switches, numbers and strings as inputs. Objects and
arrays are not rendered keep settings flat.
arrays are not rendered, so keep settings flat.
- A field carrying **`x-secret`** renders as a picker over the stored secrets
and writes a reference, `{"$secret": "name"}`, rather than the value. The
engine resolves it when the node is built, so a credential never lands in
@@ -98,13 +98,13 @@ optional `interval`.
at most that often. A connector should poll at the rate the device is
comfortable with and leave delivery rates to whoever wires it up.
- A key no port declares is an error, and it fails the whole reading rather
than the one value — a mistyped metric name is how a training curve goes
than the one value. A mistyped metric name is how a training curve goes
missing.
### When the device decides what the ports are
A connector for a device whose readings vary by model which components a
relay has, which entities were flashed onto a board cannot know its ports in
A connector for a device whose readings vary by model (which components a
relay has, which entities were flashed onto a board) cannot know its ports in
advance, and returning everything the device reports would fail on the first
value nobody bound. Narrow the reading to the ports that were declared:
@@ -115,7 +115,7 @@ return {port: value for port, value in reading.items() if port in declared}
`spec.port` is the local, unqualified name and stays that way for the node's
whole life, so the set can be taken fresh each time. Say something in the log
when a declared port is not one the device has once, not once per poll: from
when a declared port is not one the device has, once rather than once per poll: from
the canvas a renamed entity and a typo look the same, and both leave a port
silent forever.
@@ -131,7 +131,7 @@ async def poll(self) -> dict[str, Any] | None:
- Return `None` when there is nothing new.
- **Only changed values are published.** A device polled every few seconds
usually says the same thing, and every publication wakes everything
downstream, so the loop compares against what it last published what it
downstream, so the loop compares against what it last published, meaning what it
actually published, so a publication that failed is retried next tick rather
than counting as said.
- Raising is not fatal: it is reported as a health problem and retried on the
@@ -182,7 +182,7 @@ async def poll(self) -> dict[str, Any] | None:
```
`save_artifact` stores the bytes and returns what an `image`, `audio` or
`video` port carries the media type has to match the port's type. It only
`video` port carries: the media type has to match the port's type. It only
works once the node has started, since the store is the engine's and is handed
over then.
@@ -200,7 +200,7 @@ async def stop(self, app=None) -> None: ...
- `start` is called when the connector's flow starts, and after every rebuild.
- `stop` is called before a rebuild and when the flow is stopped. **It must be
idempotent** it is called whether or not `start` succeeded.
idempotent**: it is called whether or not `start` succeeded.
- Overriding either means calling `super()` if the polling loop is also wanted.
- The `app` argument is the FastAPI application, for the rare connector that
needs to mount a route. Most ignore it.
@@ -216,7 +216,7 @@ self.report_health("down", str(exc))
```
Three values, `ok`, `degraded` and `down`, plus an optional detail string.
Reporting the same status twice is free only changes are published. A node
Reporting the same status twice is free, since only changes are published. A node
reporting `down` is named among its flow's issues and counted on the health
summary on Home; `degraded` means still working, and is not. The polling loop
already reports around `poll()`; a connector managing its own connection should
+17 -20
View File
@@ -3,10 +3,8 @@
Every type the canvas can place. Each is configured by filling in a form the
editor generates from its parameter schema, so they all behave the same way.
Anything here could be written as a **Function** node — that is what the
function node is for. These exist because the same handful of shapes account
for most of a real instance, and a rule you fill in is easier to read on a
canvas, and to change, than five lines of code repeated eighty times.
Anything here can also be written as a **Function** node. These types cover the
shapes that come up most often, as a form to fill in rather than repeated code.
`GET /flows/node-types` returns this list with each type's full parameter
schema, including any [connectors](#connectors) installed on your instance.
@@ -40,13 +38,15 @@ A node with *outputs only* subscribes; a node with *inputs* publishes.
| `qos` | `0` | 0, 1 or 2 |
| `retain` | `false` | on published messages |
| `keepalive` | `60` | seconds |
| `timeout` | `10.0` | seconds before a broker operation is given up on |
| `publish_queue_size` | `256` | payloads that may wait for the broker; past this the oldest is dropped and the node reports degraded |
| `json_key` | — | key to lift out of an object payload; one for every port, or `{"port": "key"}` |
A topic may be a filter: `+` matches one level, `#` the rest. Everything a
filter matches lands on the same port, so use one port per topic where the
difference matters.
`json_key` is for a device that wraps its reading Victron publishes
`json_key` is for a device that wraps its reading: Victron publishes
`{"value": 47}` on every path. Without it, a payload object is unwrapped only
when it happens to carry the port's own name as a key.
@@ -100,10 +100,8 @@ Outputs only makes it a **webhook**: the engine mounts a route at
written, and the rows come back on the first output port as
`{"rows": [{ts, value, field, measurement, tags}], ...}`.
That is what keeps a database node a database node: it holds the credentials
and the connection and nothing else. Building a query and shaping its rows are
ordinary Function nodes on either side, so a dashboard widget never learns which
database answered it.
A database node holds the credentials and the connection, nothing else.
Building a query and shaping its rows are Function nodes on either side.
### Notification
@@ -126,7 +124,7 @@ thing configured elsewhere.
### Inject
**`inject`** — emit a value on request, on a schedule, or when the flow starts.
**`inject`** — emit a value on request, on a timer, or when the flow starts.
| Setting | Default | Notes |
|---|---|---|
@@ -138,8 +136,8 @@ thing configured elsewhere.
| `start_delay` | `1.0` | how long to wait before that first emission |
The scheduler: a `cron` expression here is what makes a flow run by the clock.
It is also the most-placed node in a real instance mostly as a button
someone presses.
It is also the most-placed node in a real instance, mostly as a button someone
presses.
### Delay & schedule
@@ -165,7 +163,7 @@ needs a busy cascade slot waits for one.
!!! note "Not in a batch flow"
A rate limit holds a value back for a timer to release, and a run has no
timer the value would be dropped rather than delayed. Submitting a batch
timer, since the value would be dropped rather than delayed. Submitting a batch
flow with a rate-limited port is refused instead.
### Trigger
@@ -183,13 +181,13 @@ starts from a value arriving. For a cron tick, see [`inject`](#inject).
| `passthrough` | `false` | send the incoming value instead of `first` |
| `wait_port` | — | an input carrying the wait, when it differs per message |
The shape this exists for: *the door opened turn the light on, and off again
in two minutes unless it opens again.*
The shape this exists for: *the door opened, so turn the light on, and off
again in two minutes unless it opens again.*
`wait_port` covers the case where how long to wait is itself a value: a
rollershutter takes 26 seconds up and 28 down, so the node that decides the
direction says how long to run for as well. A wait of zero or less sends
nothing afterwards and still cancels whatever the last message scheduled,
nothing afterwards, and still cancels whatever the last message scheduled,
which is how a *stop* is commanded exactly once.
## Logic
@@ -289,13 +287,12 @@ reloads identically.
|---|---|---|
| `seed` | `0` | |
Kept as a worked example of numeric logic rather than as a modelling tool. If
you are training something, that is a [batch flow](../concepts/runs.md) and a
Function node.
A worked example of numeric logic, not a modelling tool. To train something,
use a [batch flow](../concepts/runs.md) and a Function node.
## Connectors
Anything else in the palette came from an installed **connector** package a
Anything else in the palette came from an installed **connector** package: a
node type written against a published contract and discovered through the
`fluksio.node_types` entry point group. The editor shows which package supplied
it.
+13 -14
View File
@@ -20,11 +20,10 @@ the state backend, the work queue and the worker protocol unchanged.
| `str` | a string |
| `bool` | exactly `true` or `false` |
`bool` is an `int` subclass in Python and deliberately not a number here: a flag
is not a measurement, and a switch bound to a temperature is a mistake worth
catching.
`bool` is an `int` subclass in Python but is not a number here, so a switch
cannot bind to a temperature.
`NaN` and infinity are refused, wherever they sit including inside a `json`,
`NaN` and infinity are refused, wherever they sit, including inside a `json`,
`record`, `series` or `list`. JSON cannot spell either, so one that travelled
would come back as a response nobody can parse and a row the database rejects,
a long way from the node that made it. An empty subset or a division with no
@@ -43,8 +42,8 @@ Flat named scalars.
{"title": "Boiler", "body": "Pressure low", "severity": "warning"}
```
Nesting is deliberately out: a record that can contain a record is a schema
language, and the shape stops being readable from the declaration alone.
A record cannot contain a record, so its shape is readable from the declaration
alone.
Read by the **Notification** widget. It is also what an alert channel of kind
*dashboard* writes.
@@ -64,7 +63,7 @@ point.
### `series`
Labelled lines of `(timestamp, value)` pairs what a chart draws.
Labelled lines of `(timestamp, value)` pairs, which is what a chart draws.
```json
{
@@ -91,7 +90,7 @@ A reference to stored bytes.
{"digest": "sha256:…", "size": 4194304, "media_type": "application/octet-stream", "name": "weights.pt"}
```
Binary payloads tensors, checkpoints, images never travel as a message. The
Binary payloads (tensors, checkpoints, images) never travel as a message. The
bytes go to a content-addressed store and the message carries this. A
thirty-megabyte checkpoint never sits in the state backend, and the reference
stays valid wherever the store is reachable from, including on another machine.
@@ -110,14 +109,14 @@ The same reference, narrowed to a kind of media by its `media_type`.
An `audio` port takes `audio/*` and refuses anything else, so a speech
recogniser declares what it eats rather than taking any bytes at all and
finding out. An `artifact` port still accepts all three media narrows
finding out. An `artifact` port still accepts all three: media narrows
artifact, not the other way round.
`meta` is optional and nothing here reads it: sample rates, dimensions and
sequence numbers are for whoever consumes the media.
Bytes still never travel as a message. A camera publishes one reference per
frame and a microphone one per chunk which makes a media stream an ordinary
frame and a microphone one per chunk, which makes a media stream an ordinary
[streaming port](../concepts/flows.md#streaming-ports), and each frame an
artifact. What that costs is worth knowing before pointing a camera at it:
@@ -143,8 +142,8 @@ next author nothing.
## Naming a run's output
Any *run parameter* is also accepted as text, since nobody wants to paste an
object into a shell. `@run:<id>.<output>` names what an earlier run produced
whatever its type, an `artifact` reference or a `json` config alike — and a bare
object into a shell. `@run:<id>.<output>` names what an earlier run produced,
whatever its type, an `artifact` reference or a `json` config alike. A bare
`sha256:…` digest names content in the artifact store. Both resolve before the
run starts, so the CLI, the run dialog and a python caller all mean the same
thing by the same string.
@@ -171,8 +170,8 @@ Enforced on the server as well as in the editor.
A value that does not match its port's declaration raises on the node that
published it, naming the port and what arrived. It does not get published, and
it does not reach anything downstream — a wrong value stopping at its source is
much easier to diagnose than one propagating.
it does not reach anything downstream. A wrong value stopping at its source is
easier to diagnose than one propagating.
## See also