Merge pull request 'Research 028: the mesh's output channel' (#362) from research/028-the-meshs-output-channel into main

This commit is contained in:
2026-10-04 13:36:17 +00:00
4 changed files with 374 additions and 0 deletions
@@ -0,0 +1,71 @@
---
status: active
initiated: 2026-10-04
touches:
- 04-ISSUES/187-the-mesh-tells-nobody-when-it-stops-working/00-report.md
- 04-ISSUES/229-a-rollout-cannot-be-followed-through-the-meshs-tools/00-report.md
- 04-ISSUES/230-a-host-that-hands-over-to-a-newer-one-loses-its-report-and-a-plan-waits-for-ever/00-report.md
- 04-ISSUES/233-a-host-without-its-package-managers-configuration-refuses-the-declaration-that-would-restore-it/00-report.md
- 02-DECISIONS/0126-a-module-declares-its-own-seats.md
- 02-DECISIONS/0208-the-graphical-session-is-one-module-per-piece-on-the-meshs-seats.md
- 02-DECISIONS/0210-a-tools-configuration-is-its-seat-holders-and-every-other-module-extends-it-through-the-seat.md
- 03-DESIGN/01-to-be/32-what-a-module-declares.md
became: []
---
# 028 — The mesh's output channel
## What
How the mesh tells its operator what it noticed. The operator's framing: sending notifications
is **an output channel for the mesh**. The mesh already knows when a machine stops answering, when
a failure repeats and will not fix itself, when a rollout waits for ever. Today it keeps that to
itself until someone asks.
The effort looks at:
- **the seat:** one, held once for the mesh, that every other part uses to say something to the
operator;
- **the channels**, each a module: a desktop notification on the machine the operator is at,
**Telegram**, a phone push service, chat, mail and others (see [02](02-the-channels.md));
- **the routing**, by severity and by where the operator is;
- **the life of a message:** deduplicated while it holds, resolved when it stops, acknowledged or
silenced by the operator;
- **the watcher's watcher:** who tells the operator when the parts that would tell them are the
ones that failed.
## Why
[Issue 187](../../04-ISSUES/187-the-mesh-tells-nobody-when-it-stops-working/00-report.md) is the
class: *the mesh tells nobody when it stops working*. [01](01-what-the-mesh-already-knows.md)
counts it.
- 15 of the 236 issue reports say the fault was found because a person happened to look.
- 74 describe something failing silently.
On the day this effort opened, the mesh knew three things and told nobody:
- a workstation had refused every declaration for ninety minutes;
- the same workstation had been out of touch for ten minutes after an upgrade;
- one failure on the laptop had repeated thirteen times.
Every one of them was in `status`, for whoever asked.
The pieces exist. [To-be 32](../../03-DESIGN/01-to-be/32-what-a-module-declares.md) already uses a
`telegram-sender` seat as its worked example of a work queue with retention. ADR 0208 made a
machine's desktop notifier a node seat with a `send` verb. What is missing is a seat that speaks
for the mesh, sources that call it, and channels that deliver.
## What it touches
- **The controller**, which would become the first source of what it already computes for `status`.
- **The node-notifier seat**, which would become one channel among several.
- **Issues 187, 229, 230 and 233**, each of which ends in "and nothing said so".
- **ADR 0210**, because a channel extends the output seat through a contribution, and therefore
depends on it.
## Documents
1. [What the mesh already knows](01-what-the-mesh-already-knows.md): the evidence, and the events
that exist.
2. [The channels](02-the-channels.md): the candidates, Telegram first, weighed on the same questions.
3. [Open questions](03-open-questions.md): the seat, routing, life of a message, the watcher's
watcher, what may leave the mesh.
@@ -0,0 +1,60 @@
# 01 — What the mesh already knows, and who hears it
## The count
Over the 236 issue reports in `04-ISSUES/`, on the day this effort opened:
- **15** say, in some wording, that a person found the fault by looking: "nobody was told",
"nothing logged / said / alerted / emitted", "a person asked", "found by a person". Five of them
are still open.
- **74** describe something that failed silently.
The search was a word match over the reports' text, so it undercounts reports that tell the same
story in other words. It never overcounts by much: each of the fifteen was read.
The fifteen fall into three groups:
- **The mesh knew, and kept it in a query.** The fault was in `status`, `plans` or a node's record,
for whoever asked. Examples: a rollout waiting for ever (230), a machine refusing every declaration
(233), a setting that cannot work stored and stopping the node (096).
- **The fault was in a log nothing reads.** Examples: the bus refusing the controller's publishes
(187), a dropped report (187, 230).
- **The fault was invisible to the mesh itself.** Examples: a resolver outside the mesh closed by its
filter (198), a port narrowed without saying (086).
Only the first group is a matter of telling: the fact exists, and only delivery is missing. The
other two need a source first. This effort is about the first, and about giving the other two a
place to say something once they can.
## What the controller computes and does not say
Read from `status` and `node show` on the day this effort opened. Each line is a fact the controller
already holds:
| fact | where it is today | example that day |
|---|---|---|
| a machine is out of touch | `node show`: "last heard from — out of touch 10m" | a workstation after an upgrade |
| a machine refused its declaration | `status`: "refused" with the reason | the same workstation, for 90 minutes |
| a failure repeats and will not fix itself | `status`: "stuck: the same failure N times since …" | 13 times on the laptop |
| machines run different hosts | `status`: the version table | after a host release |
| something runs that the mesh did not write | `node show`: strays | 16 containers on one machine |
| a filter rule the mesh did not write | `status` | one machine |
| a plan is waiting | `plans` | issue 230: "for 0s", for ever |
| an assignment does not compose | the `assign` answer only | issue 235 |
None of these is published. The bus carries a seat's own events (a build's outcome), a module's
declared events, tool calls and declarations. It carries no event for any line above.
## What exists to deliver with
- **A machine's desktop:** the `node-notifier` seat (ADR 0208), held on the laptop. Its `send` verb
shows a notification, and `history` lists them. It was used through the console the day this effort
opened.
- **Mail:** a mail module provides `smtp` to the mesh.
- **Chat:** a Matrix server runs as a module on the home server.
- **Home automation:** a home-automation module runs there too, and its phone app can receive pushes.
- **A seat shape for exactly this:** to-be 32 §5 uses `telegram-sender` (`accepts: send`,
`retain 7d`, `emits: delivered, failed`, `serves: status`) as its worked example. A seat's stream
exists from registration, so work queues until a holder appears.
No module sends to Telegram, a phone push service or SMS today.
@@ -0,0 +1,116 @@
# 02 — The channels
Each channel is a candidate module that delivers what the output seat hands it. They are weighed on
the same questions:
- **Reach:** does it reach the operator away from the machines (phone), or only at a desk?
- **Off-mesh:** does it still work when the mesh's own parts (the bus, the controller, the control
node's network) are what failed?
- **Two-way:** can the operator answer through it: acknowledge, silence, ask?
- **Where the words go:** does the message leave the operator's own machines, and to whom?
- **What it costs to hold:** a secret, a server, an account, money.
## The candidates
### Telegram (required by the operator)
A bot created with Telegram's bot service sends to one chat: the operator's own, or a group.
- **Reach:** the phone and every desktop, with push.
- **Off-mesh:** sending needs only outbound HTTPS from any machine. No inbound port, no server of the
mesh's own. A second machine can hold the same bot token and send when the first is the one that
failed.
- **Two-way:** yes. Inline buttons on a message (acknowledge, silence for an hour) and commands to
the bot, read by long polling over outbound HTTPS. This makes Telegram the strongest candidate for
answering, and the riskiest (see [03](03-open-questions.md), Q7).
- **Where the words go:** to Telegram's servers. Bot chats are not end-to-end encrypted. What a
message may contain is therefore a rule this effort must set.
- **Cost:** one secret (the bot token) and the chat's id. Free. Rate limits are far above what an
operator should receive.
- **Formatting:** short text with a little markup, buttons and links. Enough for a subject, a
machine role, a severity and one line of why.
### The desktop notifier (exists)
The `node-notifier` seat's `send` verb on the machine the operator is at.
- **Reach:** only at that machine, only while a session is up.
- **Off-mesh:** no. It is reached through the mesh's tools.
- **Two-way:** dunst has actions, which a click can answer, but nothing reads them back yet.
- **Where the words go:** nowhere; it is local.
- **Cost:** none.
- **Its place:** the gentlest channel, for a warning while the operator is at a desk. "At a desk" is
itself a question: an unlocked session on a machine with recent input.
### ntfy (or Gotify): a self-hosted phone push
A small server publishes topics; its phone app subscribes.
- **Reach:** the phone, with push.
- **Off-mesh:** only if the server runs outside what failed. On the control node it fails with it.
- **Two-way:** action buttons can call a URL, which is an inbound path to design.
- **Where the words go:** stays on the operator's machines when self-hosted. ntfy's iOS push passes
through an upstream relay unless configured otherwise.
- **Cost:** a module with a container and a routed name; a token per topic.
### Matrix (a server exists as a module)
A bot account posts to a room the operator is in.
- **Reach:** phone and desktop through any Matrix client.
- **Off-mesh:** no, the server is one of the mesh's modules.
- **Two-way:** yes, by messages to the bot.
- **Where the words go:** stays on the operator's server, end-to-end encrypted if the bot supports it.
- **Cost:** a bot account, a secret.
### Mail (a mail module provides `smtp`)
- **Reach:** everywhere, without urgency.
- **Off-mesh:** no, if the mesh's own mail server sends. Yes, through an outside relay.
- **Two-way:** no, not usefully.
- **Its place:** the record and the digest: a daily summary of what was said and resolved, and the
fallback when nothing else acknowledged.
### The home-automation companion app (a module exists)
Its phone app takes pushes and actionable notifications, and the home has lights and speakers.
- **Reach:** the phone, and the house itself: a light that turns a colour.
- **Off-mesh:** no, the home server is a node.
- **Its place:** a playful critical channel, not a primary one.
### The bar on the desktop
An `i3status-rust` block showing the count of open messages, red while one is critical.
- **Reach:** the desk only, and silent.
- **Its place:** the ambient state. Nothing interrupts the operator, and they always see whether
something is open.
### The console (an agent session)
A message the next agent session opens with ("two things happened while you were away").
- **Its place:** context for the agent working on the mesh rather than an alert. It falls out of the
message store if the store is queryable.
### Others, noted and not pursued now
- **SMS or a voice call** through a paid gateway. It is the only channel that works with no data
connection, and the only one that costs per message.
- **Signal**, through an unofficial client: no bot API, and a registered number.
- **Discord or Slack** webhooks: the words go to a third party, as with Telegram, without its two-way
strength.
- **Pushover:** paid, closed, and a phone push service much like ntfy.
- **An external dead-man service** (a heartbeat URL that alerts when pings stop). It belongs to
[03](03-open-questions.md), Q6, as the watcher's watcher rather than as a channel.
## A first reading
- **Telegram** is the primary phone channel, and the only candidate that is cheap, off-mesh capable
and two-way at once.
- **The desktop notifier** is for the desk.
- **The bar** shows the ambient state.
- **Mail** carries the digest and the record.
- **ntfy and Matrix** are self-hosted alternatives for an operator who keeps words off third parties.
The seat must make that a choice, not a rewrite.
@@ -0,0 +1,127 @@
# 03 — Open questions
Each question names the options seen so far. None is decided here.
## Q1. The seat
**What speaks for the mesh to its operator?**
- **a.** One seat in the mesh's own set, held once for the mesh. Working name: `operator-channel`.
- It **accepts** `notify` (a work queue, as to-be 32 §5 designs `telegram-sender`), so a message
waits until a holder appears.
- It **emits** `delivered`, `acknowledged` and `resolved`.
- It **serves** `open` (what is unresolved now) and `history`.
- **b.** No seat: every source calls every channel. Rejected in advance, because each source would
learn every channel. This is the inversion ADR 0126 exists to prevent.
- **c.** Each channel as its own seat, with routing in the sources. Same objection as b, one level up.
Under a, the holder routes. The channels are modules that **contribute** themselves to the seat
(ADR 0210): a channel extends the seat, and so depends on it. Whether the holder is a module of its
own or part of the controller is open. A module keeps the controller small. The controller already
holds most of the facts.
## Q2. What a message is
The first shape seen: a **subject** (what it is about: a machine's role, a module, a plan), a
**kind** (out of touch, refused, stuck, late, …), a **severity**, a one-line **why**, a link to
the tool that shows more, and a **key** that makes it the same message the next time it is said.
- **Severity:** two levels (needs you now / when you can), or three (critical / warning / info)?
Every extra level is a routing rule somebody must keep right.
- **The key** is what makes deduplication possible. "Machine X out of touch" said every minute is
one message, still open, not sixty.
## Q3. The life of a message
open → (acknowledged) → resolved.
- **Deduplicate** by key while open.
- **Resolve** when the source stops saying it, or says it is over ("back in touch after 14 min"). A
channel that can edit its message (Telegram can) updates it in place rather than sending a second.
- **Acknowledge** from any channel that can answer, which stops escalation and repeats.
- **Repeat or escalate** an unacknowledged critical message after a while, to the next channel.
- **Where the open set lives:** the seat's own state, in a key-value bucket (to-be 32's `state:`), so
`open` answers after a restart.
## Q4. Routing and presence
- **By severity:** critical goes to every channel at once. A warning goes to the desk when the
operator is at one, otherwise to the phone, otherwise to the digest.
- **Presence:** "at a desk" needs a fact the mesh does not hold yet. Candidates: an unlocked
graphical session with recent input, read from the `node-lock-screen` and `node-login-manager`
seats' holders. Nothing more invasive.
- **Quiet hours:** a setting of the seat's holder (ADR 0174). Critical overrides it, or not, as the
operator chooses.
- **Rate:** a cap per hour per channel, with the excess folded into one summary, so a storm (a
network outage where every node is out of touch) arrives as one message naming many.
## Q5. The sources
The first sources are the facts in [01](01-what-the-mesh-already-knows.md), all in the controller
today:
- a machine out of touch;
- a declaration refused;
- a stuck failure;
- a plan late (once issue 230 gives a wait an age);
- an assignment that does not compose (issue 235);
- a host version split.
**How each becomes an event:**
- **a.** The controller emits an event per change of state, and the seat's holder consumes them.
- **b.** The controller calls `notify` itself.
With a, the controller learns nothing about telling: other consumers (a board, a log) get the same
facts, and the holder decides what is worth a message. With b, the controller decides severity.
**Modules as sources:** a module may `use` the seat to tell the operator something of its own
(a backup failed, a certificate is close to expiry), with the same message shape.
## Q6. The watcher's watcher
When the controller, the bus or the control node is what failed, nothing above runs. Options:
- **A dead-man signal:** the seat's holder sends a heartbeat out of the mesh (a ping to an external
heartbeat service), which alerts the operator by its own means when pings stop.
- **A second holder of the Telegram channel on another machine** that sends directly, without the
bus, when it stops hearing the controller for longer than a bound.
- **Each host** sending a last message itself when it loses the mesh for longer than a bound. This
needs the channel's secret on every machine, a cost to weigh.
The first is the cheapest and the only one that also covers "the whole house is offline".
## Q7. Answering back
Telegram, and Matrix, can carry the operator's answers.
- **Acknowledge and silence** are safe: they change only the message's state.
- **Commands** ("push the workstation", "show status") turn a chat account into a door to the
controller. If it ever comes, it needs:
- its own record;
- a narrow verb set;
- a check that the answer came from the operator's own account and chat;
- and probably a confirmation step.
The first version should probably answer with acknowledge and silence only.
## Q8. What may leave the mesh
Telegram, and any third-party channel, carries the words to someone else's servers. A message
names a machine, a module and a reason, which is operational detail.
- **What may a message contain?** Roles rather than addresses; no secrets, tokens or paths; a reason
in words. The rule must be enforced by the seat's holder, not hoped for from each source.
- **Is a self-hosted channel required for anything above a severity?**
- **The bot token and chat id** are secrets of the channel's module, delivered as any module secret is.
## Q9. How it is checked
A rule this effort produces must say how it is verified. Candidates:
- a message said twice with one key is one message;
- a resolved source resolves its message;
- a critical message reaches every channel within a bound;
- a message containing an address or a secret is refused;
- the dead-man signal fires when the holder is stopped.
Each is a test of the holder, or a live drill: stop a machine's host and time the message.