Files
mesh-catalog/modules/messenger
jschoubben 15546f9d83
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: count the store's writes, and name an ask's record when it is the only one held (review of issue 422)
The review's two nits. A write of bytes identical to what is stored was
invisible to a comparison of content, so the test store counts its
writes and the preview's tests read that count: a persist from inside
Preview now fails five of them instead of one. And a router holding an
ask's record and no condition said it held none, rather than saying what
that key is; the branches are the other way round, with a test that
holds nothing else.
2026-10-11 00:49:38 +02:00
..

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), asks (every ask, closed ones for 30 days), identities (the operator's linked accounts, one per kind, and a link asked for).
  • 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 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. What is silenced is edited to say so, with no answers and no urgency; what ended never comes back. 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 · "), 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 : " 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.

  • 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.

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.
  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).