Files
mesh-catalog/modules/messenger/README.md
T
jochen 7e27259a64
mesh/merge-gate pass: builds messenger → novox; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
messenger: end a replaced ask's own message, and drop what a cleared record owed (issue 548)
A replaced ask's message was left open as if the newer ask took it over, but
the newer ask opens a message of its own, so the old one kept offering answers
nobody takes. It now ends on every channel with 'Replaced by a newer question',
and the minute's sweep ends any such message left from before. A record that
clears no longer keeps what a walk owed of it (the review of PR 238).
2026-10-12 13:44:08 +02:00

239 lines
20 KiB
Markdown

# messenger
The holder of the `operator-channel` seat: the router (novox/hq to-be 45 §5, to-be 46, ADR 0227, ADR 0234,
ADR 0259). It decides what is said to the operator and on which channel, holds every ask a module makes of
the operator, judges each answer and issues the **warrant** its asker acts on. It performs nothing, holds no
channel's client and no channel's secret.
- **Declares three seats:** `operator-channel` (held here: `open`, `history`, `notify`, `asks`; an ask and
its cancel accepted under the asker's own name, the warrant emitted to that asker alone; the asks kept as
a record each asker reads under its own name), and the two kinded benches its channels hold, `channel`
(`show`, `edit`, `send`, each under the holder's kind) and `intake` (`choice`, `link`, `failed`, `standing`,
and the proof `code`, request and reply only). Only `channel` and `intake` may be kinded.
- **Reads the controller's open conditions** (`seat:mesh-controller.conditions`) and **consumes its
condition events**; the events are read in one place, `cmd/messenger/condition.go`.
- **The channels are modules of their own**: `telegram` (kind `telegram`, reaches the operator away, proves
its sender) and `desk-channel` (kind `desktop`, the machines' notifiers, proves nothing). Which kinds exist
and what each promises is the controller's word, in this module's membership; whether each can send now
is the channel's own, said on `intake.standing`.
- **Keeps its state on the bus** (ADR 0201): `open` and `sent` (the messages, and the restart passes made),
`asks` (every ask, closed ones for 30 days), `identities` (the operator's linked accounts, one per kind, and a
link asked for), `walks` (the delivery walks open now, each with what it holds).
- **Lost answers are said again from here, never from a channel's memory** (issue 384,
`cmd/messenger/restart.go`): a channel whose messages' answers were lost — the desk's notifier started
again — says when in its standing (`answers-lost-at`). The router says again on that kind, directly and not
through the outbox, each message that is still open, asked there, never answered and last said before that
time: one per problem, under its handle, with the words and answers of now, so it replaces the message in
place. **A restart says nothing it has said** (novox/hq ADR 0308 §6, issue 531): a silenced message, one
withheld for its content, one answered and one a walk holds are left as they are, and what ended never comes
back. The standings of one restart (a notifier on each machine, seconds apart) are one pass: a loss is acted
on once it stood 10 s, and the pass made is kept in the state (`sent`, key `passes`), so a router that starts
again does not make it again. A kind that cannot send waits; it never goes to another channel. Telegram's
answers are never lost and it says no time.
## Asks, answers and warrants (novox/hq ADR 0259)
- **An ask** arrives on `mesh.seat.operator-channel.accept.ask.<asker>`; the asker is the subject's last
token, which the bus grants that module alone. It is checked (the asks contract, the content rule, at
most three open per asker) and recorded before its work is taken; a refusal is said to the asker on its
`decided` subject, in words.
- **It is offered on every kind that can carry one of its answers**: an acknowledgement wherever a choice
can be made; an approval only where the kind proves its sender and an account is linked; nothing that
destroys until an authenticator-app code exists. Each option has a one-time ticket per kind; the words
say where an answer not offered here can be given ("Release or Stop: answer on Telegram, or from the mesh
MCP server."). An ask about a condition is shown on that condition's message.
- **An answer is judged in order**, refused at the first failure and said where it was given: the ticket is
known, unused and for this kind; the ask is open and unexpired; the sender is the linked account where
the kind proves its sender; the kind's capabilities meet the level; an approval comes from an account
linked an hour or more. The ask is then closed by compare-and-set — a second answer loses — and the
warrant published to the asker alone. A warrant the bus did not take is published again every minute.
- **What the operator sees when they answer** (issue 372): first, in reply to the answer where it was given,
that it was recorded and whether its asker was told ("You chose Approve: recorded, and claude-code was
told.") — said only once the ask's end is recorded — or, refused, why; then the ask's message edited to how
it ended, as its title ("You chose Approve at 13:30 on Telegram · <headline>"), with no answers left on it.
What the asker then did is not said: the asks protocol carries no result back to the router, so the
message says only that the asker was told and acts on it itself.
- **An ask never defaults**: at its expiry it ends `expired` and its asker does what it said it would.
- **Linking** (`/start` to the bot): a six-digit code is shown, with the account's name and number, on a
channel whose words stay private (the desk) for ten minutes; the operator sends it to the bot, and the
account is linked. It acknowledges at once and approves after an hour. **The code is shown now or never**:
while the desk cannot show it (its notifications paused, nobody there), no link is begun and no code waits
in a queue. At most three links an hour per account, and ten per kind, counted in the router's state. Every
link and unlink is said on every channel. An account unlinked less than a day ago is not linked again by a
code; one account per kind is checked again as a link is made.
- **Unlinking is asked of the account that is linked** (`messenger_unlink` asks it): an approve question put
to that account alone. Its approval unlinks it, told to it first, by name, then everywhere. **A link within
a day of an unlink waits for the replaced account's approval**, asked of it by name. Each unlink or relink
question binds the account it is about, so its approval never acts on another account linked meanwhile.
**An account still in its first hour**, which cannot approve yet, is unlinked from the desk: when another
account sends `/start`, the desk is asked whether to unlink it.
- **When that account is lost with its phone**, root at the router's machine leaves a file in this module's
state directory — `unlink.<kind>`, or `allow-link.<kind>` for a link that waits, a regular file owned by
uid 0 — and the router does it within the minute, said on every channel with what was done, and every open
question of the router's own ends. A file not made by root is refused and said.
- **A channel proving its sender says only to the linked account**: none linked, it is not ready, and asks
for approval are not offered on it. **An approval rests on the controller's positive word**, asked of its
verb `root-free` for the router's machine and the channel's, live when an answer arrives (a word at most
30 s old renders the buttons): only a machine it says is free carries an approval. A router that does not
know its machine, a controller that does not answer, a machine its answer leaves out, or a word on the
channel kinds older than two minutes all fail closed.
- **An ask that offers an approval is answered only where it could be approved**, whichever option is
chosen: the desk, which proves nothing of who clicked, is not offered it, and an acknowledgement from a
fresh link does not end it. An asker wanting an acknowledgement at the desk asks it alone.
- **Each ask's message says who asks** (as the bus names it), what each answer does, and what happens if
nobody answers. A warrant is published under a message id nobody can foresee.
- **The router runs as its own account `messenger`, on a bus account of its own**, never carried by the
machine's runtime (ADR 0259 §8): its state and its bus credential are that account's.
## What is sent, and when
**History is never news** (novox/hq issue 271). The events stream hands everything it holds to a
consumer that is new, was made again, or whose holder was away — on 2026-10-06 a newly made consumer
handed over three hours of raised-and-cleared conditions at once, and each was said as if new. So
what is said is decided by the state now and by when a thing happened, never by when it arrived:
- **The state first.** On start, every 10 min, and at once when an old event arrives, the holder reads
what is open now from the controller (`mesh-controller.conditions`). An event older than that
reading is already in it and is not acted on. What is open here and not there ended meanwhile: it is
forgotten quietly (Telegram's message is edited, which notifies nobody; the desktop is not woken). An
urgent condition open and never said is said — all of them in **one** message. A warning raised in
the last 10 min and not heard is said like any other; an older one is state.
- **Old is state.** An event whose own time (`at`) is more than **10 min** old is recorded, never said.
- **A clearing of something never said says nothing**, and a message still held when its condition
clears is dropped.
- **Start grace.** Nothing is sent in the **first minute** after start but that one urgent message;
what arrives meanwhile goes out, coalesced, when the minute ends.
**A message reads in one glance** (novox/hq ADR 0253). Its title is the condition's headline — a few
plain words such as "openrazer not working on g14" — prefixed "Urgent:", "Still open:" or "Now urgent:"
where that applies; its body is the condition's explanation (what happened, what it means, whether the
operator needs to act) and since when, in the operator's time zone. No key, id, commit, command or markup:
those stay in the controller's `conditions` and in this module's history. A clearance is one line, the
condition's resolved line and how long it was open: "Resolved: openrazer works again on g14, after 26 min".
A condition from a controller older than plain words, or a module's notice without a headline, is said from
its summary, with code spans taken out (`cmd/messenger/words.go`).
**A condition's message says which issue tracks it** (novox/hq ADR 0285, issue 400). Below the explanation,
one line from the issue tracker's `tracking` verb, asked outside the work at most once a minute and used for
ten minutes: "Tracked as issue 386: fix-386 on g14 is fixing it since 21:10.", "…: nobody is working on it.",
"…: its fix is merged and on its way.", "…: it waits for your answer.", up to three issues and then a count;
"No issue tracks this yet." when no issue links the condition's key; "Whether an issue tracks this could not
be checked." when the tracker gave no answer in ten minutes. "Nothing for you to do." becomes "Needs you:
answer issue N." while an issue tracking it waits for the operator, and is left out while nobody works on any
issue tracking it. The line changes words only, never when a message is sent (`cmd/messenger/tracking.go`).
**Every explanation opens with a verdict**: "Nothing for you to do." or "Needs you:" and one thing the
operator can do themselves. **A warning that needs nothing is not sent**: it stays open here and in the
controller's conditions, the history says it was kept quiet, and it is said the moment it needs the
operator (urgent at its bound, or its words now say "Needs you:"). An urgent condition is always said.
**A message offers the answers of the ask about it, and Details** (`cmd/messenger/answers.go`). The router
calls no verb of a condition's: the controller asks for the answers a condition names, and performs the one
chosen on its warrant. Details is a ticket per kind, good for a day, used once, shown where it was chosen. A
warning kept quiet is also said once after a day.
| When | What |
|---|---|
| `condition-raised` | one message, deduplicated by the condition's key |
| still open after 1 h (urgent) or 12 h (warning) | once more, in place of its message — never sooner than that after it was last said |
| `condition-changed` from warning to urgent | once more, in place of its message, to both channels |
| `condition-cleared` | once it stays cleared 2 min (10 min once it came back): its message edited to say so; said inside a digest, a line in the next one |
| cleared and raised again before its clearing was said | nothing: to the operator it never ended — a unit failing again after each apply is one message, not one per apply; back as urgent, or with a reminder waiting when it cleared, that is said |
| cleared, said so, and raised again within 10 min | the same message, edited back to open — not a new one (urgent: said again in its place, so it rings) |
| silenced | its message, where shown, edited to "Silenced until <day>: <headline>" with the reason, no answers and no urgency (the desk's popup times out); nothing more while it lasts; silence over and still open, the message is open again; resolved meanwhile, said only where an edit notifies nobody |
**One message per problem** (issue 373). A record keeps the id of its message on each channel; a reminder or
an escalation is said **in place of it** — the channel takes the earlier one away and shows the new one as a
new message is shown, so it rings once and no second message stands beside the first (the desk replaces the
notification by its id; Telegram deletes the earlier message). A message said again goes alone, never as a
line of a digest beside the message it follows; one first said inside a digest gets a message of its own the
next time, and later words replace or edit that one. An ask's buttons live on that one message, so an ask that
ends takes them away everywhere; its expiry is said with its day when it is not today. An ask replaced by a
newer one about the same thing ends its own message too, which then says "Replaced by a newer question"; the
newer ask shows a message of its own (issue 548). A message left open by an ask that already ended is ended on
the next minute's sweep.
- **Routing:** urgent to every kind that can send; a warning to the desk where it can send, otherwise to
the channel that reaches the operator away; an ask to every kind that carries one of its answers.
- **Bursts are one message.** What is to be said on a channel is held **30 s** from the first and goes
out together: alone, as itself; several, as one digest (`WARNING: 5 new warnings`, a line each). An
urgent message goes at once when nothing went out on its channel in the last 30 s.
- **The desktop is gentle:** warnings reach it as at most **one message every 15 min**; urgent ones
are not held to that.
- **Cap:** at most 20 messages an hour per channel — a last line of defence the above keeps far away.
Reached, it is said **once**, in a message of its own, and the rest is held to go out as one digest
when the hour allows.
- **What may leave the mesh:** roles and words. A message carrying an address (IP, host name, URL,
mail address), a path, or anything shaped like a secret (a token, a key block, a long random or
hexadecimal string, `password=…`) is refused, logged, stated as the `refused` event, and sent in its
place as `channel-refused` with the words that carried it withheld. The rule is `cmd/messenger/content.go`.
- **Nothing silent:** a channel that cannot send says so in `messenger_status` and in the log, and
what it holds is tried again every minute while the condition is open. `messenger_status` also says
when the open conditions were last read, why they could not be, and how many events were taken as
history. An event that cannot be read
is refused by name, counted, and told to the operator once an hour.
## While a delivery walks (novox/hq ADR 0308)
The controller's `plan-moved` says each walk's start and end: `walk`, `phase` (`started`, `ended`), `touches`
(`[{machine, modules}]`: what it moves and what a moved tool runner or node-engine carries), `outcome`
(`delivered`, `failed`, `stopped`), `bound_seconds` and `budget_class` (`cmd/messenger/walks.go`).
- **Held:** while a walk is open and within its bound, a message whose subject is a machine it touches, or a
module it touches (on a machine it touches, when the subject names one), is held: kept on the walk with when
and what, and the history says `held`. A message several open walks touch is told when the last ends or passes
its bound. At a walk's end, a later open walk that touches a message takes it over only as a first hold would:
still open, not silenced, not said since, and not one that is never held.
- **Never held:** an ask waiting for the operator (and a condition an open ask is about), a secret given or
replaced, a subject no open walk touches, and a walk's own failure (a plan, walk, delivery or build).
- **The end is one message:** the outcome, the modules and machines reached, how long; each held message still
open, one line each; the ones that cleared counted. Delivered with nothing held and nothing open: one line —
and nothing on a kind listed in the setting `only-what-needs-me`. What it carries is marked told on a kind
only once the message was sent there. Until then each is kept owed in the state (`owed`): a message lost with a
restart before it was sent leaves them untold and owed, and the restarted router says each on its own. A
record that clears owes nothing more.
- **The bound:** at its start plus `bound_seconds` (900 when not given), what is held and still open is said,
with "The walk … is still running, since HH:MM", and the cleared counted; the walk holds nothing more. A walk
whose end never comes is forgotten a day after it started. A start heard after its end opens nothing: the
controller's moments are compared on its own clock, never with one capped at the router's now.
- **Kept in the state** (`walks`): a router that starts again mid-walk holds and tells as before.
## What the operator gives
1. **The time zone**, as a setting: `{"time-zone": "<an IANA zone name>"}`. Every time a message says is
said in it; not given, the zone of the machine this runs on.
**The kinds that say only what needs the operator**, as a setting: `{"only-what-needs-me": ["<kind>"]}`. A
walk that delivered with nothing held and nothing open is not told on them (ADR 0308 §4); empty by default.
2. **The channels**: assign `telegram` and `desk-channel` (each says in its README what it needs). Linking
an account as the operator is done on the channel, never by a setting.
## Tools
| tool | does |
|---|---|
| `operator-channel.open` | what is open now, urgent first, with where it went, silenced, reminded, held, refused, unsent |
| `operator-channel.history` | what was said lately, and the refusals |
| `operator-channel.notify` | a message from a module using the seat: key, severity, summary, and best a plain `headline`, `explanation` and `resolved` line; `clear` to end it |
| `operator-channel.asks` | the asks held, open first: asker, state, where offered, the warrant, whether the asker was told |
| `messenger_status` | whether the operator can be reached and why not, per channel kind |
| `messenger_recent` | the recent sends, edits, digests, answers and failures |
| `messenger_test` | a test message now, to one kind or every one |
| `messenger_preview` | what a condition's message would say, tracking line and all, without sending it: the words a send would carry, reduced where the content rule refuses its own |
| `messenger_check` | whether some words may leave the mesh |
| `messenger_identities` | the accounts linked as the operator, and from when each approves |
| `messenger_unlink` | unlink a kind's account, said on every channel |
## Where it runs
Held once for the mesh, on the control node: its settings are root's files at 0600 (as every
runtime-carried module's are). It holds no secret.
## Not yet
- **`notify` is a served verb, not a work queue.** The design has the seat *accept* `notify` so a
message waits for a holder; the node's tool runtime does not yet hand a bundle its seat's queue,
and an accepted queue nobody takes would hold messages silently. It is answered request-and-reply
until the runtime takes a seat's queue for a bundle (mesh-tools).
- **Channels are inside the holder**, not modules contributing to the seat: a seat a module declares
cannot receive contributions yet (ADR 0212 kinds are compiled for the mesh's own seats).