Research 028: channels and intake as seats, decisions from a channel, Telegram as first holder
The operator asked to approve and reject through Telegram, for a generic shape in which a channel holds a seat, for input triggers, and for capabilities that decide which actions travel where. Widen 028 rather than open a new effort, since its Q1, Q7 and Q8 own the question.
This commit is contained in:
@@ -10,6 +10,14 @@ touches:
|
|||||||
- 02-DECISIONS/0208-the-graphical-session-is-one-module-per-piece-on-the-meshs-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
|
- 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
|
- 03-DESIGN/01-to-be/32-what-a-module-declares.md
|
||||||
|
- 03-DESIGN/01-to-be/45-a-core-that-cannot-fail-silently.md
|
||||||
|
- 02-DECISIONS/0190-a-seats-work-is-shared-by-its-holders-and-building-is-the-first-such-role.md
|
||||||
|
- 02-DECISIONS/0212-a-seat-says-what-it-receives-and-the-machines-hotkeys-are-a-seat.md
|
||||||
|
- 02-DECISIONS/0223-the-mesh-has-two-resolvers-and-a-machine-lists-only-them.md
|
||||||
|
- 02-DECISIONS/0227-the-core-holds-nine-rules-each-checked-and-is-built-to-them-in-six-phases.md
|
||||||
|
- 02-DECISIONS/0228-a-value-given-by-hand-lives-only-until-its-modules-first-good-start.md
|
||||||
|
- 02-DECISIONS/0230-a-consumer-the-mesh-stops-asking-for-is-retired-and-deleted-only-by-a-person.md
|
||||||
|
- 02-DECISIONS/0232-a-binding-to-a-consumers-data-moves-only-by-a-person.md
|
||||||
became: []
|
became: []
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -32,7 +40,27 @@ The effort looks at:
|
|||||||
- **the life of a message:** deduplicated while it holds, resolved when it stops, acknowledged or
|
- **the life of a message:** deduplicated while it holds, resolved when it stops, acknowledged or
|
||||||
silenced by the operator;
|
silenced by the operator;
|
||||||
- **the watcher's watcher:** who tells the operator when the parts that would tell them are the
|
- **the watcher's watcher:** who tells the operator when the parts that would tell them are the
|
||||||
ones that failed.
|
ones that failed;
|
||||||
|
- **the way back in** (widened 2026-10-06): the operator's answers, and decisions, through a channel;
|
||||||
|
and inputs from outside (a message from the operator, a mail arriving) as triggers of the same shape;
|
||||||
|
- **the capabilities** a channel declares, and the decisions that may only travel on channels whose
|
||||||
|
capabilities satisfy them.
|
||||||
|
|
||||||
|
### Why this effort widened rather than a new one opened
|
||||||
|
|
||||||
|
On 2026-10-06 the operator asked for approving and rejecting through Telegram, for a generic shape in
|
||||||
|
which Telegram simply holds a seat, for input triggers alongside output channels, and for
|
||||||
|
capabilities that decide which actions may travel on which channel. That could have opened a new
|
||||||
|
effort. It did not, because:
|
||||||
|
|
||||||
|
- every part of it hangs on this effort's open questions: Q1 (where a channel attaches), Q4
|
||||||
|
(presence), Q7 (answering back) and Q8 (what may leave);
|
||||||
|
- ADR 0227 kept answering back open **here**, and said this effort's graduation amends to-be 45 §5;
|
||||||
|
- a decision is an answer to a message this seat sends, and splitting the reply from the message
|
||||||
|
would leave two efforts each owning half of one conversation.
|
||||||
|
|
||||||
|
Input that is not an answer (a mail arriving, a webhook) shares the envelope and the seat shape, and
|
||||||
|
is designed here only as far as that shape. Its consumers are later work.
|
||||||
|
|
||||||
## Why
|
## Why
|
||||||
|
|
||||||
@@ -69,3 +97,14 @@ for the mesh, sources that call it, and channels that deliver.
|
|||||||
2. [The channels](02-the-channels.md): the candidates, Telegram first, weighed on the same questions.
|
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
|
3. [Open questions](03-open-questions.md): the seat, routing, life of a message, the watcher's
|
||||||
watcher, what may leave the mesh.
|
watcher, what may leave the mesh.
|
||||||
|
4. [Telegram, as the first holder](04-telegram-as-the-first-holder.md): making the bot, the bot
|
||||||
|
API's limits and semantics, what Telegram sees, and ten defects in the built code.
|
||||||
|
5. [The other holders, on the same axes](05-the-other-holders-on-the-same-axes.md): ntfy, Matrix,
|
||||||
|
Pushover, Gotify, mail, Signal, SMS and a dead-man service, as away channel and as the watcher's
|
||||||
|
path.
|
||||||
|
6. [Channels and triggers as seats](06-channels-and-triggers-as-seats.md): the kinded benches
|
||||||
|
`channel` and `intake`, the capability vocabulary, the router, agents as surfaces, the migration.
|
||||||
|
7. [Deciding from a channel](07-deciding-from-a-channel.md): the decisions that exist, three tiers,
|
||||||
|
decisions held by the controller, Telegram's buttons and codes, compromise.
|
||||||
|
8. [A proposed decision](08-a-proposed-decision.md): the recommendation, the operator's steps, the
|
||||||
|
tables, and a record ready for graduation.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# 03 — Open questions
|
# 03 — Open questions
|
||||||
|
|
||||||
Each question names the options seen so far. None is decided here.
|
Each question names the options seen so far. None is decided here. Q1 and Q7 are taken further in
|
||||||
|
[06](06-channels-and-triggers-as-seats.md) and [07](07-deciding-from-a-channel.md).
|
||||||
|
|
||||||
## Q1. The seat
|
## Q1. The seat
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
# 04 — Telegram, as the first holder of a channel
|
||||||
|
|
||||||
|
Telegram was the operator's first required channel ([02](02-the-channels.md)) and it is built:
|
||||||
|
the output seat's holder carries a Telegram client, and so does the watcher's watcher (to-be 45 §5).
|
||||||
|
Neither is configured, because no bot exists yet. This document is what the operator needs to make one,
|
||||||
|
what the mesh's use of the bot API must respect, and what the built code gets wrong against it.
|
||||||
|
|
||||||
|
[06](06-channels-and-triggers-as-seats.md) makes Telegram one holder of a generic channel seat rather
|
||||||
|
than the subject of the design. Everything here stays true under that shape: it is the first holder's
|
||||||
|
analysis.
|
||||||
|
|
||||||
|
Facts are as of 2026-10-06, Bot API 10.3 (2026-08-24). Sources are listed at the end.
|
||||||
|
|
||||||
|
## What the mesh uses from Telegram
|
||||||
|
|
||||||
|
Two programs send, and neither reads anything back yet:
|
||||||
|
|
||||||
|
- **The output seat's holder**, on the control node. It sends a message when a condition is raised,
|
||||||
|
says it again as a reminder, and edits the first message in place when the condition clears.
|
||||||
|
- **The watcher's watcher**, on a machine that is not the control node. It sends straight to the bot
|
||||||
|
API over HTTPS when the controller's self-check or the bus has been silent past its bound.
|
||||||
|
|
||||||
|
Each holds a **bot token** as its own secret, issued outside the mesh (ADR 0228, `issued-by: outside`),
|
||||||
|
and a **chat id** as a setting. Each sends plain text: no `parse_mode`, so no markup to escape and none
|
||||||
|
to inject.
|
||||||
|
|
||||||
|
## Making the bot
|
||||||
|
|
||||||
|
Telegram has no developer console. A bot is made by talking to Telegram's own bot, BotFather, from an
|
||||||
|
ordinary Telegram account.
|
||||||
|
|
||||||
|
- **An account is required, and an account needs a phone number.** There is no other sign-up. The
|
||||||
|
number can be a virtual one bought on Telegram's own marketplace, at a price that makes it
|
||||||
|
irrelevant here.
|
||||||
|
- **`/newbot`** asks for a display name and a username. The username is 5–32 characters of Latin
|
||||||
|
letters, digits and underscores, must end in `bot`, and cannot be changed later.
|
||||||
|
- BotFather answers with the **token**: digits, a colon, then a key. Anyone holding it controls the bot.
|
||||||
|
- **`/token`** issues a new token for the bot. The old one stops working at once. This is the rotation
|
||||||
|
path, and it is the only one.
|
||||||
|
- **`/setjoingroups` → Disable** stops anyone adding the bot to a group. The mesh's bot talks to one
|
||||||
|
person; a group is only a way for someone else to see what it says.
|
||||||
|
- **Privacy mode** (`/setprivacy`) governs what a bot sees **in groups**: with it on, only commands
|
||||||
|
meant for it, replies to it and service messages. In a private chat a bot sees everything the person
|
||||||
|
writes. With groups disabled, privacy mode does not matter; leave it on.
|
||||||
|
|
||||||
|
### A bot cannot speak first
|
||||||
|
|
||||||
|
A bot cannot open a conversation. Until the person presses **Start** in the bot's chat, every send to
|
||||||
|
them fails with a "Forbidden" error. So the operator presses Start once, on each bot.
|
||||||
|
|
||||||
|
### Finding the chat id, safely
|
||||||
|
|
||||||
|
In a private chat the chat id equals the person's user id. There are two ways to learn it:
|
||||||
|
|
||||||
|
- **Read `getUpdates` once by hand.** After pressing Start, a call to `getUpdates` returns the `/start`
|
||||||
|
message with the chat's id. It works today. Its two weaknesses: the token appears in a command line
|
||||||
|
(and so in a shell's history) unless read from a file, and it trusts that the `/start` it sees is the
|
||||||
|
operator's. A bot's username is public, and anyone who finds it can press Start too.
|
||||||
|
- **A linking verb with a one-time code.** The holder makes a short code and answers with a deep link
|
||||||
|
(`https://t.me/<bot>?start=<code>`). The operator opens it on the phone; Telegram sends `/start <code>`.
|
||||||
|
The holder reads it by `getUpdates`, and binds **that** chat and **that** user id only if the code
|
||||||
|
matches and is fresh. This proves the chat belongs to whoever held the code, and it never shows the
|
||||||
|
token to anyone. It needs the holder to read updates, which approvals need anyway
|
||||||
|
([07](07-deciding-from-a-channel.md)).
|
||||||
|
|
||||||
|
The second is the one to build. The first is the stop-gap until it exists.
|
||||||
|
|
||||||
|
### One bot or two
|
||||||
|
|
||||||
|
The holder and the watcher each have their own secret. They can hold the same token or two.
|
||||||
|
|
||||||
|
**Two bots are better:**
|
||||||
|
- Revoking one does not silence the other. The watcher exists for the day the rest is broken, and
|
||||||
|
that day must not also be the day its token was rotated away.
|
||||||
|
- The phone shows which program spoke.
|
||||||
|
- **Only one program may read a bot's updates.** Two concurrent `getUpdates` callers on one token make
|
||||||
|
Telegram answer the older with HTTP 409, "terminated by other getUpdates request". The moment the
|
||||||
|
holder reads answers, the watcher could no longer share its token with anything that reads.
|
||||||
|
|
||||||
|
## The bot API, as the mesh uses it
|
||||||
|
|
||||||
|
### Limits
|
||||||
|
|
||||||
|
- **Rate.** Telegram's FAQ: "In a single chat, avoid sending more than one message per second." In a
|
||||||
|
group, 20 messages a minute. Broadcast across chats: about 30 a second. The holder's own cap is 20 an
|
||||||
|
hour, so the limit is never near.
|
||||||
|
- **Over the limit** the API answers HTTP 429 with `parameters.retry_after`, the seconds to wait
|
||||||
|
before the request may be repeated. Repeating early prolongs the wait.
|
||||||
|
- **Length.** A text message is at most 4096 characters after entity parsing. Longer is refused with
|
||||||
|
HTTP 400, not cut.
|
||||||
|
- **Callback data** on a button is 1–64 bytes ([07](07-deciding-from-a-channel.md)).
|
||||||
|
|
||||||
|
### Editing
|
||||||
|
|
||||||
|
- **`editMessageText`** replaces a sent message's text. For an ordinary bot message there is no time
|
||||||
|
limit. The 48-hour limit applies only to business messages, and deletion has its own 48-hour limit.
|
||||||
|
- **An edit notifies nobody.** No sound, no banner, and the message stays where it was in the chat's
|
||||||
|
history. This is why a clearing is cheap to say by edit. It is also why an edit must never be the
|
||||||
|
only way something **new** is said.
|
||||||
|
- **An identical edit is an error:** HTTP 400, "message is not modified". It is harmless and must be
|
||||||
|
read as success, not as a failure to fall back from.
|
||||||
|
- **A deleted message** answers "message to edit not found". Falling back to a new message is right
|
||||||
|
then.
|
||||||
|
|
||||||
|
### Loudness
|
||||||
|
|
||||||
|
Telegram has **no message priority**. The only lever is **`disable_notification`**: the message
|
||||||
|
arrives without sound. It cannot break through the phone's do-not-disturb, so an urgent message at
|
||||||
|
night is as quiet as the phone is set to be. Per-chat notification settings on the phone (a custom
|
||||||
|
sound, an exception to do-not-disturb) are the operator's, not the mesh's.
|
||||||
|
|
||||||
|
### Formatting
|
||||||
|
|
||||||
|
With no `parse_mode` the text is shown as written, and nothing in a message can be read as markup.
|
||||||
|
That is the right default for words that pass a content rule rather than a template. If markup is ever
|
||||||
|
wanted, `MarkdownV2` needs every reserved character escaped and fails the whole send on one miss, so
|
||||||
|
plain text or `HTML` with escaping are the safer options.
|
||||||
|
|
||||||
|
`disable_web_page_preview` was **deprecated in Bot API 7.0** in favour of
|
||||||
|
`link_preview_options: {is_disabled: true}`. It still works, but the mesh's messages carry no links
|
||||||
|
(the content rule refuses URLs), so the parameter can simply be dropped.
|
||||||
|
|
||||||
|
### Answering back
|
||||||
|
|
||||||
|
Answers reach a bot two ways:
|
||||||
|
- **Long polling with `getUpdates`**, over outbound HTTPS. Updates wait at Telegram for at most
|
||||||
|
24 hours.
|
||||||
|
- **A webhook** (`setWebhook`), which Telegram calls over HTTPS on port 443, 80, 88 or 8443. It may
|
||||||
|
carry a secret header (`X-Telegram-Bot-Api-Secret-Token`) that proves the call came from the webhook
|
||||||
|
that was set.
|
||||||
|
|
||||||
|
Only one of the two at a time. [07](07-deciding-from-a-channel.md) chooses between them.
|
||||||
|
|
||||||
|
## What Telegram sees
|
||||||
|
|
||||||
|
- **Everything in the message.** A bot chat is a "cloud chat": encrypted between the phone and
|
||||||
|
Telegram, and between Telegram and the bot API caller, and readable by Telegram. Bots cannot take
|
||||||
|
part in Telegram's end-to-end "secret chats".
|
||||||
|
- **That is what the content rule is for.** The holder refuses any message carrying an address, a
|
||||||
|
path or a secret's shape (to-be 45 §5), so what Telegram stores is roles, words and condition keys.
|
||||||
|
- **Who the operator is.** The account's phone number, and the addresses the phone and the sending
|
||||||
|
machines connect from. Since September 2024 Telegram's privacy policy says it may disclose a user's
|
||||||
|
IP address and phone number to judicial authorities on a valid order.
|
||||||
|
- **Machine names.** A condition key names the machine it is about, and so does the watcher's message
|
||||||
|
("told by mesh-watcher on …"). The content rule refuses host names with a top-level domain, not bare
|
||||||
|
machine names. To-be 45 says a message's subject is "a machine's role". Whether a bare machine name
|
||||||
|
may leave is a decision this effort has not taken; today it does.
|
||||||
|
|
||||||
|
## When Telegram is unreachable
|
||||||
|
|
||||||
|
- **A send fails at the transport** (no DNS, no connection, timeout). The holder keeps what it held
|
||||||
|
and tries again every minute. The watcher keeps what it owes and tries again at its next tick.
|
||||||
|
Neither loses a message while it runs.
|
||||||
|
- **A send fails because Telegram refuses** (400 or 403). It is permanent for that message. The holder
|
||||||
|
today treats it like a transport failure and tries again every minute, for ever (see the defects).
|
||||||
|
- **Telegram being down is invisible to Telegram.** The holder's status says the channel is failing.
|
||||||
|
The operator sees that only through another channel or by asking. This is what a second holder of a
|
||||||
|
different kind is for ([05](05-the-other-holders-on-the-same-axes.md)).
|
||||||
|
- **Telegram is blocked** in some countries and on some networks. An operator travelling should know
|
||||||
|
the mesh's phone channel may be one of them.
|
||||||
|
|
||||||
|
## Cost
|
||||||
|
|
||||||
|
- **Free.** No per-message charge. Telegram's paid broadcasts (above 30 messages a second) are far
|
||||||
|
out of range.
|
||||||
|
- **One account**, which the operator very likely already has.
|
||||||
|
- **Two secrets**, one token per bot, both `issued-by: outside`.
|
||||||
|
|
||||||
|
## The built code, checked against this
|
||||||
|
|
||||||
|
Read from the code repository's main branch on 2026-10-06: the holder's `telegram.go`, `outbox.go`,
|
||||||
|
`holder.go`, `content.go`, and the watcher's `telegram.go` and `watcher.go`. The two Telegram clients
|
||||||
|
are copies of each other, kept apart on purpose so the watcher depends on nothing it watches.
|
||||||
|
|
||||||
|
What is right:
|
||||||
|
- **Plain text**, with no `parse_mode`.
|
||||||
|
- **The token is kept out of every error.** The client rebuilds transport errors from their kind,
|
||||||
|
because the URL carries the token. It also strips the token from the API's own description.
|
||||||
|
- **Token and chat id are re-read at each send**, so accepting the secret or changing the setting needs
|
||||||
|
no restart.
|
||||||
|
- **A token's shape is checked.** A random value the mesh minted for an un-accepted secret is named as
|
||||||
|
that, not sent to Telegram to be refused.
|
||||||
|
- **A missing edit falls back to a new message.**
|
||||||
|
- **The holder caps itself** at 20 messages an hour and folds bursts into digests, far inside
|
||||||
|
Telegram's limits.
|
||||||
|
|
||||||
|
### Defects
|
||||||
|
|
||||||
|
| # | Where | What | Effect | Weight |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| D1 | holder: `telegram.go`, `outbox.go` | Nothing bounds a message to 4096 characters. A long summary, or a digest of long titles, is refused with 400. `failed` keeps the whole batch and retries it every minute. | One oversized message wedges the Telegram channel: everything queued behind it waits for ever. | high |
|
||||||
|
| D2 | holder: `holder.go` (`h.edit(old, "reopened", false)`) | A condition that clears and is raised again within ten minutes is said by **editing** the first message. On Telegram an edit notifies nobody. | A reopened urgent condition reaches the phone **silently**, high up in the chat's history. | high |
|
||||||
|
| D3 | holder: `outbox.go` (`r.Sent[name]` keeps only the first id) | Reminders and escalations are new messages, but clearing edits only the first. | The newest thing on the phone still says "STILL OPEN" or "NOW URGENT" after the condition cleared. The "CLEARED" is a silent edit, out of sight. | medium |
|
||||||
|
| D4 | both: `telegram.go` | `Message.Quiet` and `Message.Urgent` are ignored. `disable_notification` is never set. | A clearing, or a warning, rings as loudly as an urgent message. Telegram's only loudness lever is unused. | medium |
|
||||||
|
| D5 | both: `telegram.go` (`call`) | HTTP 429's `parameters.retry_after` is not read. The retry is a flat minute. | Harmless at the holder's cap. Under a real flood wait, repeating early prolongs it. | low |
|
||||||
|
| D6 | holder: `telegram.go`, `outbox.go` | Every refusal (400, 403) is retried like a transport failure. "message is not modified" on an edit is read as a failed edit, and a new message is sent instead. | Permanent errors loop every minute in the log. A no-op edit becomes a duplicate message. | low |
|
||||||
|
| D7 | both: `telegram.go` | `disable_web_page_preview` is deprecated since Bot API 7.0. | Works today. Moot, since no message carries a link. Drop it. | low |
|
||||||
|
| D8 | both: `telegram.go` (`call`) | The `json.Marshal` error is discarded. A non-numeric `message_id` (`json.Number`) makes the body empty. | A confusing refusal from Telegram instead of a local error. Ids come from Telegram, so it is unlikely. | low |
|
||||||
|
| D9 | watcher: `watcher.go` (`Tick`) | A "silent" message that could not be sent is overwritten by the "CLEARED" message when the signal returns. | The operator can receive "heard again" for a silence they were never told of. Better to say both, or one line saying it was silent for N minutes and is back. | low |
|
||||||
|
| D10 | both | `Ready()` is satisfied by a token and a chat id. It does not know whether the operator pressed Start, or whether the bot was blocked (403). | Status says "ready" until the first send fails. The watcher's own test verb is the only proof. A `getChat` check at status time would say it. | low |
|
||||||
|
|
||||||
|
D1 and D2 matter before the channel is configured. D1 can silence the channel. D2 silences exactly the
|
||||||
|
case (a flapping urgent condition) the operator most needs to hear.
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
- Telegram, *Bots FAQ*: rate limits, paid broadcasts. https://core.telegram.org/bots/faq
|
||||||
|
- Telegram, *Bot API* (version 10.3, recent changes, `getUpdates` retention, `ResponseParameters`,
|
||||||
|
`setWebhook`, `link_preview_options`). https://core.telegram.org/bots/api
|
||||||
|
- Telegram, *Bot features*: BotFather, `/newbot`, `/token`, `/setprivacy`, deep linking.
|
||||||
|
https://core.telegram.org/bots/features
|
||||||
|
- `link_preview_options` replacing `disable_web_page_preview` (Bot API 7.0); the removal of the old
|
||||||
|
argument in python-telegram-bot v22. https://docs.python-telegram-bot.org/en/v22.0/telegram.ext.defaults.html
|
||||||
|
- "message is not modified" and "message to edit not found" in practice:
|
||||||
|
https://github.com/tdlib/telegram-bot-api/issues/400
|
||||||
|
- 409 "terminated by other getUpdates request":
|
||||||
|
https://community.home-assistant.io/t/help-on-telegram-extension-error-while-getting-updates-conflict-terminated-by-other-getupdates-request-make-sure-that-only-one-bot-instance-is-running-409/177544
|
||||||
|
- Telegram privacy policy change, September 2024:
|
||||||
|
https://www.bleepingcomputer.com/news/security/telegram-now-shares-users-ip-and-phone-number-on-legal-requests/
|
||||||
|
- Bots and secret chats; cloud-chat encryption:
|
||||||
|
https://www.kaspersky.com/blog/telegram-privacy-security/38444/
|
||||||
|
- Phone number required; anonymous numbers: https://en.wikipedia.org/wiki/Telegram_(software)
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
# 05 — The other holders, on the same axes
|
||||||
|
|
||||||
|
Every candidate is judged as a holder of the channel and intake seats in
|
||||||
|
[06](06-channels-and-triggers-as-seats.md): which capabilities it can honestly declare
|
||||||
|
(the vocabulary is defined there), and what it needs. [02](02-the-channels.md) weighed the same
|
||||||
|
candidates before any of this was measured. This document replaces its reading of them with facts as
|
||||||
|
of 2026-10-06, and adds the question 02 could not ask: **can the operator decide something through
|
||||||
|
it?** ([07](07-deciding-from-a-channel.md)).
|
||||||
|
|
||||||
|
Two roles are judged separately, because they want different things:
|
||||||
|
|
||||||
|
- **The away channel:** the mesh's urgent messages and its decisions, wherever the operator is.
|
||||||
|
- **The watcher's path:** the message that the mesh itself has gone silent. It must not depend on the
|
||||||
|
control node, the bus or the controller. The mesh observed has its controller, its bus and its mail
|
||||||
|
server on the anchor (the control node). Its Matrix server is on the home-server, behind a household
|
||||||
|
connection.
|
||||||
|
|
||||||
|
## The candidates
|
||||||
|
|
||||||
|
### ntfy
|
||||||
|
|
||||||
|
A small push server. Topics are published to over HTTP; the phone app subscribes.
|
||||||
|
|
||||||
|
- **Self-hosted vs the public server.** Self-hosted keeps the words on the operator's machines.
|
||||||
|
The public `ntfy.sh` takes no sign-up. Its free tier allows 250 messages a day **per IP address**,
|
||||||
|
shared with whoever else sends from that address. Paid tiers (from about $5–6 a month) give
|
||||||
|
reserved topics and higher quotas. An unreserved topic on the public server is readable by anyone
|
||||||
|
who guesses its name.
|
||||||
|
- **Phone delivery.**
|
||||||
|
- **Android:** through Google's FCM from the public server, or through the app's own long-lived
|
||||||
|
connection to a self-hosted server ("instant delivery"), which costs battery.
|
||||||
|
- **iOS:** cannot be reached by a self-hosted server alone. The server must name an upstream
|
||||||
|
(`upstream-base-url`, normally `ntfy.sh`), which receives a poll request carrying only a message
|
||||||
|
id and a hash of the topic, and has Apple wake the phone. The words do not pass through the
|
||||||
|
upstream. The dependency does.
|
||||||
|
- **Loudness:** five priorities. The highest gives "really long vibration bursts" and a pop-over on
|
||||||
|
Android.
|
||||||
|
- **Answers:** up to three action buttons. An `http` action makes **the phone** send a request,
|
||||||
|
which needs a route from the phone to the mesh and a credential carried inside the notification.
|
||||||
|
Nothing tells the server **who** tapped, only that someone holding the notification did. No free-text
|
||||||
|
reply.
|
||||||
|
- **As the watcher's path:** self-hosted, it fails with the machine it runs on. The public server
|
||||||
|
works, at the cost of a guessable topic or a subscription.
|
||||||
|
|
||||||
|
### Matrix (a homeserver is already one of the mesh's modules)
|
||||||
|
|
||||||
|
The module runs Conduit and Element Web, on the home-server.
|
||||||
|
|
||||||
|
- **Reach:** any Matrix client on the phone. Push goes from the homeserver to the client's **push
|
||||||
|
gateway**: for the stock Element apps, Element's gateway at matrix.org, which hands it to Apple or
|
||||||
|
Google. A self-hosted gateway needs a self-built app. UnifiedPush (via ntfy) is an option on Android.
|
||||||
|
- **Push support in Conduit has lagged.** Its own documentation long listed mobile push as missing,
|
||||||
|
and forks have since reworked pushers. Whether the running version pushes reliably is **unverified**
|
||||||
|
and must be measured before Matrix is relied on for anything urgent.
|
||||||
|
- **Answers:** free text, and reactions (`m.reaction` annotations) as one-tap choices. Clients add
|
||||||
|
emoji variation selectors, which must be normalised before a reaction is read as a choice.
|
||||||
|
- **Who answered:** the sender's Matrix id is authenticated **by the homeserver**, which the mesh
|
||||||
|
runs. That is strong for an account on the mesh's own server, and only as strong as that server.
|
||||||
|
- **Privacy:** end-to-end encrypted if the bot supports it. Otherwise readable by the homeserver,
|
||||||
|
which is the mesh's own.
|
||||||
|
- **As the watcher's path:** it survives the control node going down. It does not survive the home's
|
||||||
|
connection going down, and its phone push still depends on matrix.org's gateway.
|
||||||
|
- **Cost:** a bot account and its secret. No third party for the words.
|
||||||
|
|
||||||
|
### Pushover
|
||||||
|
|
||||||
|
A paid push service with a stable API.
|
||||||
|
|
||||||
|
- **Cost:** $4.99 one-time per platform after a 30-day trial. 10,000 messages a month per
|
||||||
|
application.
|
||||||
|
- **Limits:** 1024 characters, a 250-character title.
|
||||||
|
- **Loudness:** the strongest of any candidate.
|
||||||
|
- Priority 1 bypasses the user's quiet hours.
|
||||||
|
- Priority 2 ("emergency") repeats every `retry` seconds (at least 30) until acknowledged or until
|
||||||
|
`expire` (at most three hours). It returns a **receipt** that can be polled outbound to learn
|
||||||
|
whether, and when, it was acknowledged.
|
||||||
|
- **Answers:** acknowledgement only. No buttons, no reply.
|
||||||
|
- **As the watcher's path:** yes. It is outbound HTTPS from any machine, to a third party.
|
||||||
|
- **Where the words go:** to Pushover.
|
||||||
|
|
||||||
|
### Gotify
|
||||||
|
|
||||||
|
Self-hosted, Android only. Delivers over a WebSocket the app keeps open. There is no official iOS app,
|
||||||
|
and Apple's restrictions make a self-hosted iOS push impossible without a relay. It has no answer path
|
||||||
|
beyond opening the app. **Not pursued:** it covers less than ntfy and nothing ntfy does not.
|
||||||
|
|
||||||
|
### Mail through an outside provider
|
||||||
|
|
||||||
|
- **The mesh's own mail server is on the control node,** so it fails with it.
|
||||||
|
- **An outside relay** (an SMTP account at a mail provider) reaches the operator from any machine.
|
||||||
|
- **Urgency:** none. Mail is the digest and the record.
|
||||||
|
- **Answers:** a reply, slowly. **Who answered is weak:** a From line can be forged, and checking
|
||||||
|
DKIM only proves the operator's provider sent it.
|
||||||
|
- **Mail as an intake** (a new mail arriving) is a trigger in its own right
|
||||||
|
([06](06-channels-and-triggers-as-seats.md)), whatever its weakness as a channel for decisions.
|
||||||
|
|
||||||
|
### Signal, through `signal-cli`
|
||||||
|
|
||||||
|
- **An unofficial client,** and it needs its own phone number, registered with a captcha.
|
||||||
|
- **It must be kept current.** Signal's own clients expire after three months, and the server then
|
||||||
|
changes incompatibly. In March 2026 Signal began unregistering accounts whose client lacked a new
|
||||||
|
protocol feature, and every `signal-cli` account registered before that date was dropped.
|
||||||
|
- **Privacy:** end-to-end encrypted. Sender identity is strong (Signal's identity keys). Reactions
|
||||||
|
and replies both work.
|
||||||
|
- **Weight:** a phone number and a maintenance burden with a hard failure mode. It is the right
|
||||||
|
choice only for an operator who requires end-to-end encryption on the phone and accepts that burden.
|
||||||
|
|
||||||
|
### SMS through a paid gateway
|
||||||
|
|
||||||
|
The only candidate that reaches a phone with **no data connection**.
|
||||||
|
|
||||||
|
- **Cost:** per message, with an account at a gateway.
|
||||||
|
- **Privacy:** no encryption. Sender identity on replies is spoofable.
|
||||||
|
- **Its place:** the last resort of an outside dead-man service (below), which offers SMS and phone
|
||||||
|
calls on paid plans, rather than a channel of the mesh's own.
|
||||||
|
|
||||||
|
### An outside dead-man service
|
||||||
|
|
||||||
|
A service the mesh **pings**, which alerts by its own means when the pings stop. It is not a channel
|
||||||
|
of the mesh: it is the one thing that still speaks when **every** machine, or the home's connection
|
||||||
|
and the anchor together, are gone. [03](03-open-questions.md) Q6 named it the cheapest answer that
|
||||||
|
also covers "the whole house is offline".
|
||||||
|
|
||||||
|
- **Healthchecks.io** (also self-hostable, which defeats the point here) monitors 20 checks free,
|
||||||
|
without a card.
|
||||||
|
- It notifies through Telegram, Signal, Matrix, ntfy, Pushover, mail and others.
|
||||||
|
- Paid plans add SMS, WhatsApp and phone-call credits.
|
||||||
|
|
||||||
|
## The table
|
||||||
|
|
||||||
|
Capabilities are those of [06](06-channels-and-triggers-as-seats.md). ✓ declared honestly,
|
||||||
|
— not, ~ conditional (the note says on what).
|
||||||
|
|
||||||
|
| Holder | reaches-away | loud | silent | edit | choice | reply | verified-sender | exact-render | second-factor | private | off the control node | cost |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Telegram | ✓ | — (do-not-disturb wins) | ✓ | ✓ | ✓ | ✓ | ✓ (user id) | ✓ | ✓ (code as a reply) | — | ~ (a holder on another machine) | free |
|
||||||
|
| desktop notifier | — | ~ (critical urgency) | ✓ | ✓ | ~ (actions, read by nobody) | — | — (any program of the account) | ✓ | — | ✓ | — | none |
|
||||||
|
| ntfy, self-hosted | ✓ | ✓ (priority 5) | ✓ | — | ~ (http action, phone → mesh) | — | — | ✓ | — | ✓ (iOS: relay sees ids) | — | a module |
|
||||||
|
| ntfy.sh | ✓ | ✓ | ✓ | — | ~ | — | — | ✓ | — | — | ✓ | free (250/day/IP) or ~$5/mo |
|
||||||
|
| Matrix (own server) | ~ (push unverified) | — | ✓ | ✓ | ✓ (reactions) | ✓ | ✓ (own homeserver) | ✓ | ✓ | ~ (E2E if the bot does it) | ~ (home-server, not anchor) | a bot account |
|
||||||
|
| Pushover | ✓ | ✓✓ (emergency, repeats) | ✓ | — | ~ (acknowledge only) | — | ✓ (for acknowledge) | ✓ | — | — | ✓ | $4.99 once |
|
||||||
|
| mail, outside relay | ✓ (slow) | — | ✓ | — | — | ✓ | — (forgeable) | ✓ | ~ (code in a reply) | — | ✓ | an account |
|
||||||
|
| Signal (`signal-cli`) | ✓ | — | ✓ | ✓ | ✓ (reactions) | ✓ | ✓ | ✓ | ✓ | ✓ | ~ | a number + upkeep |
|
||||||
|
| SMS gateway | ✓ (no data needed) | ✓ | — | — | — | ~ | — | ✓ | — | — | ✓ | per message |
|
||||||
|
|
||||||
|
## Reading it
|
||||||
|
|
||||||
|
**For the away channel and for decisions,** Telegram is the only candidate that is all of these at
|
||||||
|
once: free, on both phone platforms, without a server of the mesh's own, and able to carry every tier
|
||||||
|
of decision ([07](07-deciding-from-a-channel.md)), including a second factor. Its price is that
|
||||||
|
Telegram reads the words, which is what the content rule is for.
|
||||||
|
- **Matrix** is the self-hosted equivalent for decisions. It waits on a measurement of Conduit's push.
|
||||||
|
- **Signal** is the end-to-end-encrypted equivalent, at a maintenance cost that has already broken
|
||||||
|
every installation once this year.
|
||||||
|
|
||||||
|
**For waking the operator,** Pushover's emergency priority is the only thing that repeats until
|
||||||
|
acknowledged and gets through quiet hours. Telegram cannot. It is a reasonable **second** away holder
|
||||||
|
for urgent conditions only, if the operator wants to be woken. It cannot carry a decision beyond
|
||||||
|
"acknowledged".
|
||||||
|
|
||||||
|
**For the watcher's path,** the requirement is independence from what it watches:
|
||||||
|
- **Telegram, sent directly** from a machine that is not the control node, with its own bot, meets it
|
||||||
|
(to-be 45 §5).
|
||||||
|
- **ntfy self-hosted does not.**
|
||||||
|
- **Matrix only half meets it** (it is on the home-server, and its push goes through matrix.org).
|
||||||
|
- **What none of them covers** is the watcher's own machine, or the home's connection, going down
|
||||||
|
together with the anchor. Only an outside dead-man service covers that.
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
- ntfy, *Configuration* (iOS upstream relay, FCM, access control): https://docs.ntfy.sh/config/
|
||||||
|
- ntfy, *Publishing* (priorities, actions, `http` action, message size): https://docs.ntfy.sh/publish/
|
||||||
|
- ntfy.sh pricing: https://ntfy.sh/#pricing. Its free-tier rate limit is per IP:
|
||||||
|
https://github.com/binwiederhier/ntfy/issues/1963
|
||||||
|
- Pushover API (length, quota, priorities, emergency retry/expire, receipts): https://pushover.net/api
|
||||||
|
- Pushover pricing: https://pushover.net/pricing
|
||||||
|
- Gotify, platform support: https://play.google.com/store/apps/details?id=com.github.gotify and
|
||||||
|
https://guancyxx.cn/en/blog/ntfy-vs-gotify-vs-nostr
|
||||||
|
- Element push gateway (Sygnal at matrix.org):
|
||||||
|
https://github.com/vector-im/element-android/blob/develop/docs/notifications.md
|
||||||
|
- Matrix reactions (`m.annotation`): https://github.com/uhoreg/matrix-doc/blob/aggregations-reactions/proposals/2677-reactions.md
|
||||||
|
- Conduit changelog: https://conduit.rs/changelog/
|
||||||
|
- signal-cli, registration with a captcha: https://github.com/AsamK/signal-cli/wiki/Registration-with-captcha
|
||||||
|
- signal-cli, unregistration of outdated clients in 2026: https://github.com/AsamK/signal-cli/issues/1993
|
||||||
|
- Healthchecks.io pricing: https://healthchecks.io/pricing/. Its Telegram integration:
|
||||||
|
https://healthchecks.io/integrations/telegram/
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
# 06 — Channels and triggers as seats
|
||||||
|
|
||||||
|
The operator's direction, 2026-10-06, in substance:
|
||||||
|
|
||||||
|
- **Telegram is one form of output channel among many to come.** The setup must be generic, and
|
||||||
|
Telegram simply fulfils a seat.
|
||||||
|
- **The same holds for input:** a new mail, a new message from the operator, a button pressed.
|
||||||
|
- **Each channel has capabilities,** human approval buttons for example, and these force some actions
|
||||||
|
to be allowed only through some channels.
|
||||||
|
|
||||||
|
This document is the generic shape. [04](04-telegram-as-the-first-holder.md) and
|
||||||
|
[05](05-the-other-holders-on-the-same-axes.md) are its first holders.
|
||||||
|
[07](07-deciding-from-a-channel.md) is the first thing it carries that is not a notification: a
|
||||||
|
decision.
|
||||||
|
|
||||||
|
## What exists, and how it is shaped
|
||||||
|
|
||||||
|
Read from the catalogue's main branch on 2026-10-06.
|
||||||
|
|
||||||
|
- **The output seat's holder is one module doing three jobs.** It claims `operator-channel` (mesh
|
||||||
|
scope, serving `open`, `history` and `notify`). It consumes the controller's three condition events.
|
||||||
|
It holds the Telegram bot token as its own secret. It reaches the desktop by invoking the
|
||||||
|
`node-notifier` seat's `send`.
|
||||||
|
- So the router, the Telegram client and the desktop adapter are one process with one manifest.
|
||||||
|
- `notify` is a served verb, not the work queue to-be 32 §3 sketched for a `telegram-sender` seat.
|
||||||
|
- To-be 45 §5 says each channel is "a module contributing itself to the seat". The code does not do
|
||||||
|
that, and on a closer look that shape does not fit (below).
|
||||||
|
- **The desktop notifier** is a node seat, `node-notifier`, held by the dunst module on each graphical
|
||||||
|
machine (`send`, `history`).
|
||||||
|
- **The watcher's watcher** is a second module with its own Telegram client and token. It sends
|
||||||
|
directly, by design, and is not assigned anywhere yet.
|
||||||
|
- **Nothing reads anything back.** No input from the operator reaches the mesh except through an agent
|
||||||
|
session or a shell.
|
||||||
|
|
||||||
|
## Where a channel sits: [03](03-open-questions.md) Q1, asked again
|
||||||
|
|
||||||
|
Q1 settled that **one seat speaks for the mesh** and that sources never learn channels. It left open
|
||||||
|
how a channel attaches to that seat, and assumed contribution. With many channels to come, and with
|
||||||
|
channels that answer, that is the question to settle.
|
||||||
|
|
||||||
|
The mesh's seat model offers these precedents:
|
||||||
|
|
||||||
|
- A **seat** has one holder at its scope (ADR 0121, ADR 0126).
|
||||||
|
- A **node seat** has one holder per machine, and a verb's subject carries the machine
|
||||||
|
(`…tool.<verb>.<node>`, design 33 §4).
|
||||||
|
- A **bench** is a seat with several holders (glossary). The only one is `mesh-dns-resolver`: the
|
||||||
|
same module, one per machine. "Making another one is a decision, recorded" (ADR 0223).
|
||||||
|
- A **work queue** is shared by a seat's holders, and one of them takes each ask (ADR 0190).
|
||||||
|
- A **contribution** is content another module hands to a seat's holder, which places it
|
||||||
|
(ADR 0210, ADR 0212).
|
||||||
|
|
||||||
|
The options:
|
||||||
|
|
||||||
|
- **a. Each channel contributes itself to the output seat** (Q1 a, to-be 45 §5).
|
||||||
|
- A contribution is content: a rule file, a hotkey, a fragment the holder places (ADR 0212).
|
||||||
|
- A channel is running code. It holds its own secret, keeps a connection, polls for answers, and
|
||||||
|
fails on its own. None of that is content.
|
||||||
|
- To make it fit, the router would have to carry every channel's client, which is today's
|
||||||
|
monolith again. **Rejected.**
|
||||||
|
- **b. One seat per channel kind** (`telegram-channel`, `ntfy-channel`, …).
|
||||||
|
- The router must learn each seat, and every new channel is a change to the router.
|
||||||
|
- That is Q1 c's objection moved one level down. **Rejected.**
|
||||||
|
- **c. Channel modules found by a manifest field and called by module address.**
|
||||||
|
- ADR 0126: callers use the seat, never the module. **Rejected.**
|
||||||
|
- **d. One monolithic notifier,** with every channel built into the router (today's shape, grown).
|
||||||
|
- Every channel's secrets, dependencies and failures share one process.
|
||||||
|
- A new channel is a release of the router.
|
||||||
|
- The watcher's independence becomes the only exception, instead of the rule's natural case.
|
||||||
|
- **Rejected.**
|
||||||
|
- **e. A kinded bench.** One mesh seat, `channel`, whose holders are **different modules**, each
|
||||||
|
claiming it under a **kind** (`telegram`, `desktop`, `ntfy`, `matrix`, `mail`, …).
|
||||||
|
- One holder per kind. Two modules claiming one kind is refused at registration, as two modules
|
||||||
|
claiming one machine of a bench are today.
|
||||||
|
- A verb's subject carries the kind, as a node seat's carries the machine:
|
||||||
|
`mesh.seat.channel.tool.send.<kind>`.
|
||||||
|
- The router addresses "the channel seat, kind telegram", never a module.
|
||||||
|
- A new channel is a new module claiming a new kind, with no change to the router.
|
||||||
|
- **Chosen.**
|
||||||
|
|
||||||
|
Option **e** needs two things the mesh does not have yet:
|
||||||
|
|
||||||
|
- **A second bench, of a new sort.** Its holders are different modules, keyed by kind instead of
|
||||||
|
machine. ADR 0223 requires that to be decided.
|
||||||
|
- **A claim that carries a kind and capabilities.**
|
||||||
|
|
||||||
|
Both are small beside what they buy. The same shape serves input.
|
||||||
|
|
||||||
|
## The channel seat (output)
|
||||||
|
|
||||||
|
**`channel`**, mesh scope, a kinded bench.
|
||||||
|
|
||||||
|
### What a holder serves
|
||||||
|
|
||||||
|
- **`send`:** a message (title, body, severity, silent or not, optional choices — see
|
||||||
|
[07](07-deciding-from-a-channel.md), optional reply-to handle). Answers the channel's id for the
|
||||||
|
message, or a refusal in words.
|
||||||
|
- **`edit`:** replace a sent message by its id. Only for a holder that declares `edit`.
|
||||||
|
- **`reply`:** say something into a conversation, by the handle an intake envelope carried. Only for
|
||||||
|
a holder that declares `reply`.
|
||||||
|
- **`standing`:**
|
||||||
|
- ready, or not configured (and what is missing, named, never a value), or failing (since when,
|
||||||
|
why);
|
||||||
|
- the last delivery;
|
||||||
|
- the declared capabilities.
|
||||||
|
|
||||||
|
### What a holder emits
|
||||||
|
|
||||||
|
- **`delivered`** and **`failed`**, each carrying the message's key and the channel's id. A refusal
|
||||||
|
is marked **permanent** or **transient**, so the router never retries a permanent refusal for ever
|
||||||
|
(defect D6 in [04](04-telegram-as-the-first-holder.md)).
|
||||||
|
|
||||||
|
### What a holder must honour
|
||||||
|
|
||||||
|
- **Its declared maximum length:** cut and say so, never refuse and wedge (D1).
|
||||||
|
- **`silent`** where it declares it (D4).
|
||||||
|
- **An identical edit is a success** (D6).
|
||||||
|
- **The token and every other own secret stay out of every error and event,** as the Telegram
|
||||||
|
client already does.
|
||||||
|
|
||||||
|
### The content rule moves to the router
|
||||||
|
|
||||||
|
A message bound for a holder that does not declare `private` passes the content rule first. The rule
|
||||||
|
is the router's, so no holder can forget it.
|
||||||
|
|
||||||
|
## The capability vocabulary
|
||||||
|
|
||||||
|
**Fixed and versioned:** `channel-capabilities/1`. A holder declares capabilities in its claim. The
|
||||||
|
catalogue refuses a word outside the vocabulary.
|
||||||
|
|
||||||
|
**A declaration is a promise with a test.** For each capability the catalogue holds a contract test
|
||||||
|
the holder's module must pass at build time, and a live drill the holder's `standing` can run on
|
||||||
|
request. An undeclared capability is never assumed; a declared one that fails its drill is reported as
|
||||||
|
`channel-capability-broken` and withdrawn from routing until it passes.
|
||||||
|
|
||||||
|
### Delivering
|
||||||
|
|
||||||
|
| Capability | Promise | Contract test / drill |
|
||||||
|
|---|---|---|
|
||||||
|
| `deliver` | A message reaches the operator's device, or `failed` says why. | Send to a test double. Live: the holder's test message. |
|
||||||
|
| `reaches-away` | It reaches a phone away from the operator's machines. | Declared by kind. Drill: the operator acknowledges a test. |
|
||||||
|
| `loud` | It can break through the phone's quiet hours. | The holder maps urgent to the service's override. Drill acknowledged. |
|
||||||
|
| `silent` | It can deliver without sound. | The request carries the service's silent flag. |
|
||||||
|
| `edit` | A sent message can be replaced in place. | Edit, then read back, against a double. |
|
||||||
|
| `max-length:N` | Messages up to N characters arrive whole. Longer ones are cut, and the cut is said. | N+1 characters give a cut message, not a failure. |
|
||||||
|
| `reaches-when-mesh-down` | Delivering needs neither the bus nor the control node. | Declared only by a holder not on the control node, with no bus call on the send path. Checked by the manifest's placement. |
|
||||||
|
| `private` | The words stay on the operator's machines, or are end-to-end encrypted. | Declared by kind. Reviewed, not tested. |
|
||||||
|
|
||||||
|
### Answering ([07](07-deciding-from-a-channel.md))
|
||||||
|
|
||||||
|
| Capability | Promise | Contract test / drill |
|
||||||
|
|---|---|---|
|
||||||
|
| `choice` | The operator can pick one of the offered options in one act, and the pick comes back as an intake event. | A simulated tap gives a `choice` envelope with the option's token. |
|
||||||
|
| `reply` | The operator can answer in free text, on the same conversation. | A simulated reply gives a `reply` envelope with the handle. |
|
||||||
|
| `verified-sender` | The holder proves the act came from the operator's own account on that service, by the service's authentication, with nothing in between that relays words. | A tap from a non-allowlisted identity is dropped and reported. A relayed text never carries the flag. |
|
||||||
|
| `exact-render` | A decision is shown as the controller rendered it, by the holder itself. No agent or other program composes the words the operator decides on. | The rendered text equals the controller's, byte for byte, against a double. |
|
||||||
|
| `second-factor` | The holder can carry a short code the operator types, to the controller, which verifies it. The holder never judges the code. | A code reply is handed to the controller by request and reply, never as an event, and is deleted from the conversation where the service allows. |
|
||||||
|
|
||||||
|
## The intake seat (input)
|
||||||
|
|
||||||
|
**`intake`**, mesh scope, a kinded bench, held by the same sort of modules. Telegram claims both
|
||||||
|
`channel` and `intake` under the kind `telegram`, because one program must read the bot's updates
|
||||||
|
([04](04-telegram-as-the-first-holder.md)).
|
||||||
|
|
||||||
|
A holder turns something from outside into **one envelope**, and emits it as an event on the seat:
|
||||||
|
|
||||||
|
| Field | What it is |
|
||||||
|
|---|---|
|
||||||
|
| `id` | Unique, for deduplication. |
|
||||||
|
| `kind` | The holder's kind: `telegram`, `mail`, `matrix`, `webhook`, … |
|
||||||
|
| `what` | `message` (the operator wrote), `choice` (a button or reaction), `reply` (an answer to something the mesh sent), `mail`, `call` (a webhook), `seen` (the operator was active here). |
|
||||||
|
| `sender` | The identity on that service (a user id, a Matrix id, a mail address), and `verified`: whether the service authenticated it **and** the holder declares `verified-sender`. |
|
||||||
|
| `operator` | Whether that identity is on the **controller's** list of the operator's identities. The holder fills it from the controller's list, never from a list of its own. |
|
||||||
|
| `conversation` | An opaque handle. Answering on it (`channel.reply <kind> <handle>`) goes back to the same chat, room or mail thread. |
|
||||||
|
| `in-reply-to` | The mesh message or decision it answers, if any. |
|
||||||
|
| `payload` | The text, the choice's token, the mail's subject and body. It passes the same content discipline as everything on the bus: **no secrets in events.** A second-factor code is never in an envelope. |
|
||||||
|
| `at` | When. |
|
||||||
|
|
||||||
|
**Consumers subscribe by `what`:**
|
||||||
|
- the router: `seen`, for where the operator is;
|
||||||
|
- the controller: `choice` and `reply` to decisions ([07](07-deciding-from-a-channel.md));
|
||||||
|
- an agent bridge: `message`, from a verified operator;
|
||||||
|
- a future mail rule: `mail`.
|
||||||
|
|
||||||
|
**Who "the operator" is, per kind, is the controller's record,** not each holder's setting. A channel
|
||||||
|
module cannot widen it. Adding an identity is itself a decision ([07](07-deciding-from-a-channel.md)).
|
||||||
|
|
||||||
|
**One gap to close:** design 32 §1 notes the shared library cannot yet publish on a seat's event
|
||||||
|
subjects (`mesh.seat.<seat>.event.<x>`). Intake needs it.
|
||||||
|
|
||||||
|
## The router
|
||||||
|
|
||||||
|
The output seat's holder becomes **only** the router. It keeps:
|
||||||
|
- `operator-channel`: its messages, their life (deduplication, reminders, clearing), the rate cap,
|
||||||
|
`open` and `history`;
|
||||||
|
- and gains the decisions to deliver ([07](07-deciding-from-a-channel.md)).
|
||||||
|
|
||||||
|
### How it chooses, per message
|
||||||
|
|
||||||
|
1. **What the message needs.** A notification needs `deliver`. A decision needs what its tier
|
||||||
|
requires ([07](07-deciding-from-a-channel.md)). Urgent prefers `reaches-away` and `loud`.
|
||||||
|
2. **Where the operator is.** The most recent verified `seen` or `message` on an intake kind within a
|
||||||
|
bound (15 minutes, a setting) wins. Next comes the desktop when its session answers. Last is the
|
||||||
|
operator's chosen **away channel** (a setting naming a kind).
|
||||||
|
3. **Only channels whose declared capabilities satisfy the message,** filtered by `standing`.
|
||||||
|
4. **Nothing able to carry it:** the router says so on whatever can deliver, and keeps it open as a
|
||||||
|
condition of its own ("a decision is waiting and no channel can carry it: telegram is failing
|
||||||
|
since …"). It never degrades a decision to a channel that cannot carry it.
|
||||||
|
|
||||||
|
## Agents as surfaces
|
||||||
|
|
||||||
|
An agent session is a place the operator talks to the mesh. It is a surface with capabilities like
|
||||||
|
any other.
|
||||||
|
|
||||||
|
- **An agent in a terminal** relays the operator's words. It declares nothing for deciding:
|
||||||
|
- not `verified-sender`, because what reaches the mesh is the agent's call, not the operator's act;
|
||||||
|
- not `exact-render`, because the agent composes what the operator reads.
|
||||||
|
|
||||||
|
An agent must never approve on the operator's behalf because "the user said yes". It **requests**
|
||||||
|
a decision ([07](07-deciding-from-a-channel.md)); the request goes to a capable channel; the
|
||||||
|
operator decides there. The agent sees the outcome as an event. Having to open Telegram while
|
||||||
|
working in a terminal is acceptable to the operator.
|
||||||
|
- **An agent the operator talks to through Telegram** is reached by the intake: verified `message`
|
||||||
|
envelopes from the operator go to an agent bridge, and its answers go back by `channel.reply` on the
|
||||||
|
same conversation. When it needs a decision it requests one with the conversation's handle. The
|
||||||
|
router puts the decision **into that conversation**, rendered by the Telegram holder, with its
|
||||||
|
buttons. The operator's tap is the holder's verified act, not the agent's relay. So the decision is
|
||||||
|
made in place. The operator cannot switch to a desk while working this way, so nothing may require
|
||||||
|
one ([07](07-deciding-from-a-channel.md)).
|
||||||
|
|
||||||
|
## The watcher, in this shape
|
||||||
|
|
||||||
|
The watcher's watcher stays **outside** the seats, deliberately:
|
||||||
|
- its whole point is to speak when the bus and the control node are what failed;
|
||||||
|
- the router and the seats live on the bus.
|
||||||
|
|
||||||
|
It shares the vocabulary only: its sender is the minimal `deliver` + `reaches-when-mesh-down` client
|
||||||
|
it already is, with **its own bot**. It never reads updates and never carries a decision.
|
||||||
|
|
||||||
|
Its sibling outside the mesh is the dead-man service ([05](05-the-other-holders-on-the-same-axes.md)),
|
||||||
|
which the self-check and the watcher both ping.
|
||||||
|
|
||||||
|
## From today to this shape
|
||||||
|
|
||||||
|
The steps are ordered so that each one is useful alone.
|
||||||
|
|
||||||
|
1. **Fix the first holder in place:** D1–D4 of [04](04-telegram-as-the-first-holder.md), in the
|
||||||
|
output seat's holder and the watcher. No change of shape.
|
||||||
|
2. **The operator configures Telegram:** two bots ([08](08-a-proposed-decision.md)). The mesh starts
|
||||||
|
telling.
|
||||||
|
3. **The vocabulary and the kinded bench.** The catalogue learns `kind` and `capabilities` on a claim,
|
||||||
|
the vocabulary file and its contract tests. Registration refuses two holders of one kind.
|
||||||
|
4. **Split Telegram out** into its own module, holding `channel` and `intake` under `telegram`.
|
||||||
|
- The router keeps the desktop adapter as the holder of `channel/desktop`, until a reason appears
|
||||||
|
to move it next to dunst.
|
||||||
|
- The bot token moves with the module, as an own secret `issued-by: outside`, accepted again.
|
||||||
|
5. **Decisions in the controller,** and buttons on Telegram ([07](07-deciding-from-a-channel.md)).
|
||||||
|
6. **Intake for the operator's messages,** and an agent bridge consuming them.
|
||||||
|
7. **Further holders as wanted:** Pushover for waking, Matrix once its push is measured, mail out
|
||||||
|
through an outside relay for the digest, mail in as an intake.
|
||||||
|
|
||||||
|
The watcher changes only at step 1.
|
||||||
|
|
||||||
|
## What this revisits in [03](03-open-questions.md)
|
||||||
|
|
||||||
|
- **Q1:** the channels attach as holders of a kinded bench (e), not as contributions.
|
||||||
|
- **Q3:** "a channel that can edit" becomes the declared `edit`. Whether a clearing is said by edit
|
||||||
|
depends on `silent` as well (D2, D3).
|
||||||
|
- **Q4:** presence gains a source: verified intake activity.
|
||||||
|
- **Q7:** answering back is designed in [07](07-deciding-from-a-channel.md).
|
||||||
|
- **Q8:** the content rule stays and moves to the router. A `private` holder may be exempted, a
|
||||||
|
choice left to graduation.
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
# 07 — Deciding from a channel
|
||||||
|
|
||||||
|
The operator, 2026-10-06:
|
||||||
|
|
||||||
|
- "Make sure I can approve and reject stuff via the Telegram channel."
|
||||||
|
- In a terminal session with an agent, having to open Telegram to react is acceptable.
|
||||||
|
- But when talking to an agent **through** Telegram, the operator cannot switch to a desktop session.
|
||||||
|
|
||||||
|
So every decision must be completable on the operator's away channel, including the strongest, and
|
||||||
|
no agent may ever decide in the operator's place.
|
||||||
|
|
||||||
|
## Where this stands against what was decided
|
||||||
|
|
||||||
|
- To-be 45 §5 says: "No answering back in this form; acknowledging is `conditions silence`, through the
|
||||||
|
mesh."
|
||||||
|
- ADR 0227 adopted that minimal form, and kept the rest open on purpose: "routing by presence, quiet
|
||||||
|
hours, **answering back** and the external dead-man service stay open in 028, whose graduation amends
|
||||||
|
to-be 45."
|
||||||
|
- So answering back is not a reversal of ADR 0227. It is this effort's open question Q7, now
|
||||||
|
answered. Its graduation amends to-be 45 §5.
|
||||||
|
|
||||||
|
## What there is to decide
|
||||||
|
|
||||||
|
Read from the controller's main branch on 2026-10-06. The condition store already marks the
|
||||||
|
conditions only a person resolves: `resolver: operator`. That is set from the start for retirement,
|
||||||
|
clean-up and binding conditions, and set when a healer's budget is spent.
|
||||||
|
|
||||||
|
| Decision | The verb today | Asked for by | Reversible | Tier |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Approve the retirement set waiting | `retire approve <node> <provider> --why` | `retire-waiting` (urgent) | yes: asking for a consumer again re-enables it | approve |
|
||||||
|
| Reject it | `retire reject … --why` | `retire-waiting` | yes: a rejected set can be approved later | approve |
|
||||||
|
| Approve a set rejected before | `retire approve …` | `retire-rejected` (warning) | yes | approve |
|
||||||
|
| Confirm that a binding moves, once its data is moved | `pin <node> <provision> <from> <module>` | `binding-kept` (urgent) | the pin, yes (`unpin`). The data, not by the mesh. | approve |
|
||||||
|
| End a stuck plan | `plans stop` / `plans close <id> --why` | `stalled`, `sent-not-reported` once escalated | no, but it destroys nothing | approve |
|
||||||
|
| Send a machine its declaration by hand | `push <node> --why` | `sent-not-reported` once escalated | n/a | approve |
|
||||||
|
| Reset a bus consumer's position | `broker consumer-reset --why` | `consumer-behind` once escalated | no: messages are skipped or redelivered | approve (graduation to confirm) |
|
||||||
|
| Try a healer's repair once more, by hand | the healer's ordinary path | any healer escalation (H1–H5), `healers-braked` | as the repair is | approve |
|
||||||
|
| Silence a condition for a while | `conditions silence <key> --for --why` | any | yes: it ends by itself, at most 7 days | acknowledge |
|
||||||
|
| Delete one retired consumer's data | `cleanup delete <node> <provider> <consumer> --why` | `cleanup-waiting` (warning, after 30 days) | **no** | destroy |
|
||||||
|
| Delete everything retired longer than N days | `cleanup delete --older-than N --confirm --why` | `cleanup-waiting` | **no** | destroy |
|
||||||
|
| Add an identity to the operator's list, change the away channel, enrol a second factor | (new) | none | yes, but it changes who may decide | destroy |
|
||||||
|
|
||||||
|
Not offered on a channel in the first form: build queue verbs (`cancel`, `kill`, `clear`, `pause`),
|
||||||
|
`replay --register`. No condition asks for them, and they remain at the console.
|
||||||
|
|
||||||
|
## Three tiers, each a set of required capabilities
|
||||||
|
|
||||||
|
The capabilities are those of [06](06-channels-and-triggers-as-seats.md).
|
||||||
|
|
||||||
|
| Tier | Required of the channel | And of the decision |
|
||||||
|
|---|---|---|
|
||||||
|
| **acknowledge** | `choice`, `verified-sender`, `exact-render` | single use, expires with the condition |
|
||||||
|
| **approve** | `choice`, `verified-sender`, `exact-render` | single use, bound to the exact state shown, expires when that state changes or after 24 h |
|
||||||
|
| **destroy** | `choice`, `reply`, `verified-sender`, `exact-render`, `second-factor` | as approve, plus a code from the operator's authenticator verified **by the controller**, valid 10 minutes after it is shown, at most one destroy decision answered per 10 minutes |
|
||||||
|
|
||||||
|
### The rule
|
||||||
|
|
||||||
|
1. **Every decision declares its tier,** in the controller's verb table (below).
|
||||||
|
2. **A decision is offered only on a channel whose declared capabilities satisfy its tier.** Arriving
|
||||||
|
from any other channel, it is refused, and the refusal is said on the channel it came from.
|
||||||
|
3. **The operator's away channel must satisfy every tier.** The self-check verifies it. A design or a
|
||||||
|
setting that would make a tier possible only at a desk is refused, unless the operator has said so
|
||||||
|
explicitly for that tier, in a setting the self-check reads.
|
||||||
|
4. **No agent decides.** An agent requests. It never holds a verb that performs a decision, and a
|
||||||
|
decision's record names the agent that asked.
|
||||||
|
|
||||||
|
## The controller carries decisions
|
||||||
|
|
||||||
|
Today a grant covers a whole verb (`seat:mesh-controller.retire` allows approve and listing alike).
|
||||||
|
Hand-acts record `by` from the caller's bus principal, which for a channel module would be the
|
||||||
|
module, not the person. Both call for a decision to be a thing the controller holds.
|
||||||
|
|
||||||
|
### The verb table gains a field
|
||||||
|
|
||||||
|
The controller's verb definition (its table of the mesh's own verbs) has a name, a description and
|
||||||
|
input and output schemas. It gains **`decision`**: the tier, and which arguments make up the exact
|
||||||
|
state a person must see (for `retire approve`, the set of consumers).
|
||||||
|
|
||||||
|
### Three new verbs
|
||||||
|
|
||||||
|
- **`decide request`.** Any principal may call it: an agent, a module, the router on a condition's
|
||||||
|
behalf. It carries the action (verb and exact arguments), why, and optionally a conversation handle.
|
||||||
|
- The controller renders the decision's text itself: what is asked, the exact state (for example the
|
||||||
|
consumers in the set), who asks, what each option does.
|
||||||
|
- It stores the decision in its own state with an opaque id (10 random base32 characters), the
|
||||||
|
expiry, and a digest of the state shown.
|
||||||
|
- It emits `decision-requested`. **It never performs anything.**
|
||||||
|
- **`decide answer`.** Only intake holders are granted it. It carries the id, the option, the sender's
|
||||||
|
identity as the service authenticated it, and the code for a destroy decision. The controller
|
||||||
|
checks, in order, and refuses with the reason at the first failure:
|
||||||
|
1. the decision exists, is open, and has not expired;
|
||||||
|
2. the **calling principal** is the holder of an intake kind, read from the controller's own seat
|
||||||
|
records, never from the request;
|
||||||
|
3. that kind's **declared** capabilities, from the controller's own records, satisfy the tier;
|
||||||
|
4. the sender's identity is on the controller's list of the operator's identities for that kind;
|
||||||
|
5. for destroy: the code is valid for the current or previous 30-second step, and not used before;
|
||||||
|
6. the state **now** has the digest it had when shown (for `retire approve`, the waiting set is
|
||||||
|
exactly the one rendered). Otherwise the decision is void and a new one is requested.
|
||||||
|
|
||||||
|
Then it performs the action as itself, records the hand-act, closes the decision with a
|
||||||
|
compare-and-set on its stored revision (so a second answer from another channel loses), and emits
|
||||||
|
`decision-answered`.
|
||||||
|
- **`decisions`:** open and recent decisions, for any reader.
|
||||||
|
|
||||||
|
### The verbs that decide become reachable only this way
|
||||||
|
|
||||||
|
The decision-bearing verbs (`retire approve|reject`, `cleanup delete`, `pin` while a `binding-kept`
|
||||||
|
names it) refuse a caller unless the call comes through `decide answer`, or carries a valid
|
||||||
|
second-factor code as **break-glass** at the console. Break-glass is recorded as such, and announced on
|
||||||
|
every channel. So the console, which agents can drive, never decides without a code only the operator
|
||||||
|
holds.
|
||||||
|
|
||||||
|
- `retire approve` must accept the set it is approving. Today it re-reads the set at the moment it
|
||||||
|
runs, so "approve what you were shown" does not hold end to end (ADR 0230). It needs an `expect`
|
||||||
|
argument the provider compares.
|
||||||
|
- `conditions silence` stays callable as today (it hides, it destroys nothing). A silence set by an
|
||||||
|
agent is said on the away channel with its why.
|
||||||
|
|
||||||
|
### Who answered, in the record
|
||||||
|
|
||||||
|
The hand-act gains:
|
||||||
|
- **`via`:** the kind and the holder module;
|
||||||
|
- **`requested-by`:** the agent principal or the condition key;
|
||||||
|
- **`decision`:** the id;
|
||||||
|
- **`factor`:** whether a code was verified.
|
||||||
|
|
||||||
|
`by` becomes "the operator, as <kind> identity <id>". The decision's messages on every channel are
|
||||||
|
edited to the outcome: "approved by the operator on telegram at 14:02 UTC". Its buttons are removed.
|
||||||
|
|
||||||
|
## Telegram, as the first holder that decides
|
||||||
|
|
||||||
|
- **Buttons.** Each decision message carries an inline keyboard. A button's `callback_data` is at
|
||||||
|
most 64 bytes, so it carries only `d1:<id>:<option>` (about 16 bytes). Everything else is in the
|
||||||
|
controller.
|
||||||
|
- **A tap** arrives as a `callback_query` with the tapping user's id and the message's chat id. The
|
||||||
|
holder:
|
||||||
|
1. drops it, and reports, unless the user id is on the operator's list for `telegram` and the chat is
|
||||||
|
the bound chat;
|
||||||
|
2. calls `answerCallbackQuery` at once, because the phone shows a spinner until it does;
|
||||||
|
3. calls `decide answer`;
|
||||||
|
4. edits the message to the outcome, or to the refusal ("expired: a new one was sent", "the set
|
||||||
|
changed", "already decided on matrix at …").
|
||||||
|
- **A destroy decision** answers the tap with a `ForceReply` prompt: "Reply with your 6-digit code to
|
||||||
|
delete <item>". The operator's reply (same user, same chat) is read, deleted from the chat (bots may
|
||||||
|
delete incoming messages in private chats), and handed to the controller by request and reply. It
|
||||||
|
is never put in an event.
|
||||||
|
- **Long polling, not a webhook.**
|
||||||
|
- `getUpdates` runs over outbound HTTPS from wherever the holder runs, and needs no route into the
|
||||||
|
mesh.
|
||||||
|
- A stolen token can steal updates, but cannot inject one into the holder's stream. A second reader
|
||||||
|
shows up as HTTP 409, which the holder reports as a condition.
|
||||||
|
- A webhook needs a public route to the holder's machine, and a stolen token can redirect it.
|
||||||
|
- The holder keeps its update offset in its own state, so a restart does not hand a tap over twice.
|
||||||
|
Single-use ids would refuse it anyway.
|
||||||
|
- Telegram keeps an unread update for 24 hours. A decision unanswered longer expires before that
|
||||||
|
matters.
|
||||||
|
- **Free text** from the operator is an intake `message`, for an agent bridge (below). Free text is
|
||||||
|
never read as a decision: only a tap, or a code replying to a destroy prompt, decides.
|
||||||
|
|
||||||
|
## Agents and decisions
|
||||||
|
|
||||||
|
- **In a terminal:**
|
||||||
|
1. The agent calls `decide request`.
|
||||||
|
2. The router delivers the decision to the operator's away channel, or to wherever verified
|
||||||
|
activity was most recent.
|
||||||
|
3. The operator taps there.
|
||||||
|
4. The agent sees `decision-answered`.
|
||||||
|
|
||||||
|
The agent cannot tap, and nothing it says counts.
|
||||||
|
- **Through Telegram:** the operator's messages reach the agent as verified intake envelopes. The
|
||||||
|
agent answers by `channel.reply` on the conversation handle. When it calls `decide request` with
|
||||||
|
that handle, the router delivers the decision **into the same chat**, as a reply in the thread. It is
|
||||||
|
rendered by the holder from the controller's text, with its buttons. The tap is the operator's own
|
||||||
|
verified act, so the decision is made in place, destroy tier included, with nothing that needs a
|
||||||
|
desk.
|
||||||
|
|
||||||
|
## Desktop and console, noted
|
||||||
|
|
||||||
|
- **The desktop notifier** can show action buttons (dunst returns the chosen action). Any program
|
||||||
|
running as the operator's account can also invoke a notification's action, and agents run as that
|
||||||
|
account. So the desktop declares no `verified-sender`. It shows decisions as information, with
|
||||||
|
"decide on telegram". Nothing is decided there.
|
||||||
|
- **The console** (a person's account, a shell or an agent's tools) declares `exact-render` only for
|
||||||
|
the controller's own output and never `verified-sender`, for the same reason. It may decide only as
|
||||||
|
break-glass, with a code.
|
||||||
|
|
||||||
|
## If the phone, the account or a part is compromised
|
||||||
|
|
||||||
|
| What is lost | What the attacker can do | What limits it |
|
||||||
|
|---|---|---|
|
||||||
|
| The bot token | Read what the bot is sent from then on. Steal taps by polling (seen as 409). Send the operator fake messages. | It cannot answer a decision: only the holder's bus account can call `decide answer`. Revoke with BotFather's `/token`. |
|
||||||
|
| The operator's Telegram account, on a new device | Tap approve or acknowledge. | Telegram's two-step verification password. Every decision is announced on the other channels and in the digest. Approve-tier acts are reversible. Destroy needs the code. |
|
||||||
|
| The phone, unlocked | Everything, including destroy, if the authenticator is on the same phone and open. | An authenticator locked behind the phone's biometrics. One destroy per 10 minutes, each announced. Backups of the data a delete removes (research 030). A setting that turns destroy off for the away channel, at the operator's choice. |
|
||||||
|
| The channel module, or its bus account | Forge a verified approve or acknowledge. | It cannot forge a code: the controller verifies codes itself. Its grant is `decide answer` alone. |
|
||||||
|
| An agent (prompt injection) | Request decisions, with persuasive words in its why. | It cannot answer. The decision's text is rendered by the controller, naming the agent that asked. |
|
||||||
|
| Telegram itself | Read the words. In principle, forge a tap. | The content rule. Destroy needs the code, which Telegram never sees. |
|
||||||
|
|
||||||
|
The second factor is a TOTP seed held by the controller as its own secret, made by the mesh. It is
|
||||||
|
enrolled by showing its URI once, only to a terminal (refused when the output is not one), never
|
||||||
|
through a channel or an event. Re-enrolment is a destroy decision.
|
||||||
|
|
||||||
|
## The alternatives, for deciding
|
||||||
|
|
||||||
|
- **Matrix:** reactions as choices, replies, and a sender authenticated by the mesh's own homeserver.
|
||||||
|
It can declare every capability Telegram does, and `private` with an encrypting bot. It is the
|
||||||
|
self-hosted holder for decisions once its push is measured.
|
||||||
|
- **ntfy:** an `http` action button makes the phone call a URL, which needs a route into the mesh and a
|
||||||
|
credential inside the notification. Nothing tells the server who tapped. No reply, so no code. It
|
||||||
|
can declare no tier.
|
||||||
|
- **Pushover:** acknowledgement, read back by polling a receipt outbound, and nothing else. It can
|
||||||
|
carry `acknowledge` at most. Its strength is waking the operator, not asking them.
|
||||||
|
- **Mail:** a reply can carry a code, but the sender is forgeable. No tier, except as break-glass.
|
||||||
|
- **Signal:** like Telegram, end-to-end encrypted, at its upkeep cost ([05](05-the-other-holders-on-the-same-axes.md)).
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
As in [04](04-telegram-as-the-first-holder.md), and:
|
||||||
|
- Bot API `callback_data` (1–64 bytes), `answerCallbackQuery`, `ForceReply`, `deleteMessage` in private
|
||||||
|
chats: https://core.telegram.org/bots/api
|
||||||
|
- `answerCallbackQuery` is required even with no text, or the client keeps its progress indicator:
|
||||||
|
https://gramio.dev/telegram/methods/answercallbackquery
|
||||||
|
- `setWebhook` ports and the secret-token header: https://core.telegram.org/bots/api#setwebhook
|
||||||
|
- ntfy `http` actions: https://docs.ntfy.sh/publish/
|
||||||
|
- Pushover receipts: https://pushover.net/api
|
||||||
|
- Matrix reactions and their variation selectors in practice:
|
||||||
|
https://github.com/MarioCakeDev/zooid/pull/14
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
# 08 — A proposed decision, and what the operator does now
|
||||||
|
|
||||||
|
This is the effort's reading as of 2026-10-06, written so that playbook 02 can turn it into a record
|
||||||
|
and amend to-be 45 §5. It is a proposal: nothing here is decided until it graduates.
|
||||||
|
|
||||||
|
## The recommendation, short
|
||||||
|
|
||||||
|
1. **Channels and intake are seats.** One kinded bench each, `channel` and `intake`. Each holder
|
||||||
|
is a module of its own, claiming a kind and declaring capabilities from a fixed, versioned
|
||||||
|
vocabulary, each capability with a contract test and a drill.
|
||||||
|
2. **The output seat's holder becomes only the router.** It picks channels by what a message needs
|
||||||
|
(capabilities), where the operator is (verified activity), and severity. It never degrades a
|
||||||
|
decision to a channel that cannot carry it.
|
||||||
|
3. **Telegram is the first holder of both seats,** and the operator's away channel. It is free, on both
|
||||||
|
phone platforms, needs no server of the mesh's own, and is the only candidate able to carry every
|
||||||
|
decision tier, including a second factor typed as a reply.
|
||||||
|
4. **Decisions are the controller's.** An agent or a condition **requests** one. The controller
|
||||||
|
renders it and binds it to the exact state shown. A verified tap on a capable channel **answers**
|
||||||
|
it. The controller checks the channel's declared capabilities from its own records, and verifies a
|
||||||
|
destroy decision's code itself.
|
||||||
|
5. **Three tiers:** acknowledge, approve, destroy. Destroy also needs a TOTP code, so a stolen bot
|
||||||
|
token, a hijacked Telegram account or a compromised channel module cannot delete anything.
|
||||||
|
6. **Every tier is completable on the away channel.** The self-check verifies it. Nothing needs a desk
|
||||||
|
unless the operator chose that for a tier.
|
||||||
|
7. **No agent decides.** In a terminal, it requests and the operator taps on Telegram. Through Telegram,
|
||||||
|
the decision appears in the same chat and the operator taps there.
|
||||||
|
8. **The watcher's watcher stays outside the seats,** with its own bot on a machine that is not the
|
||||||
|
control node. An outside dead-man service (free) covers the case where the watcher's own
|
||||||
|
connection is gone too.
|
||||||
|
9. **Pushover is the optional second away holder** for waking the operator, which Telegram cannot do
|
||||||
|
through do-not-disturb. **Matrix** is the self-hosted decision holder, once its push is measured.
|
||||||
|
10. **The built Telegram code needs D1–D4 fixed before it is configured** ([04](04-telegram-as-the-first-holder.md)).
|
||||||
|
|
||||||
|
## What the operator does
|
||||||
|
|
||||||
|
Minimal, in order. Steps 1–6 are possible today. Step 7 waits for the dead-man ping to be built.
|
||||||
|
|
||||||
|
1. **Make two bots.** In Telegram, open BotFather.
|
||||||
|
- `/newbot` once for the mesh's messages, once for the watcher, each with a username ending in `bot`.
|
||||||
|
- Keep each token where only you can read it. Do not paste it into an agent session.
|
||||||
|
2. **Close them to groups:** `/setjoingroups` → Disable, for each bot.
|
||||||
|
3. **Press Start** in each bot's chat. A bot cannot write to you first.
|
||||||
|
4. **Turn on Telegram's two-step verification** (Settings → Privacy and Security), if it is not on.
|
||||||
|
5. **Find your chat id.** Until the linking verb exists, call `getUpdates` once for one bot, reading
|
||||||
|
the token from a file rather than typing it, and take `message.chat.id` from your `/start`. It is
|
||||||
|
the same for both bots.
|
||||||
|
6. **Give the mesh the values,** through the controller, never on disk:
|
||||||
|
- accept the mesh bot's token as the output seat's holder's own secret `telegram-token`, and set
|
||||||
|
its `telegram-chat-id`;
|
||||||
|
- assign the watcher to a machine that is not the control node, accept the watcher bot's token as
|
||||||
|
its own secret, and set its chat id;
|
||||||
|
- push both machines, then run each module's test verb and see the two messages arrive.
|
||||||
|
7. **Later, once built:** make a free Healthchecks.io check, with its own alert to Telegram and mail,
|
||||||
|
and give its ping address to the mesh as a secret. Then enrol an authenticator for destroy
|
||||||
|
decisions, at a plain terminal.
|
||||||
|
|
||||||
|
## What would change in the code (proposal, not built)
|
||||||
|
|
||||||
|
### Output seat's holder and watcher, now
|
||||||
|
|
||||||
|
- **D1:** cut at 4096 characters, and say so.
|
||||||
|
- **D2:** a reopening is a new message, never an edit.
|
||||||
|
- **D3:** a clearing edits every message of the condition, or replies silently to the first.
|
||||||
|
- **D4:** set `disable_notification` for warnings and clearings.
|
||||||
|
- **D5–D10** of [04](04-telegram-as-the-first-holder.md).
|
||||||
|
|
||||||
|
### Catalogue and controller, next
|
||||||
|
|
||||||
|
- A claim carries `kind` and `capabilities`. A kinded bench refuses two holders of one kind. The
|
||||||
|
vocabulary file `channel-capabilities/1` and its contract tests.
|
||||||
|
- The shared library publishes on a seat's event subjects (`mesh.seat.intake.event.<what>`).
|
||||||
|
- The controller's verb definition gains `decision` (tier, the arguments that are the exact state).
|
||||||
|
- New verbs `decide request`, `decide answer`, `decisions`. Events `decision-requested` and
|
||||||
|
`decision-answered`.
|
||||||
|
- `retire approve` takes the set it approves (`expect`).
|
||||||
|
- Decision-bearing verbs refuse direct calls without a code (break-glass).
|
||||||
|
- Hand-acts gain `via`, `requested-by`, `decision` and `factor`.
|
||||||
|
- The list of the operator's identities per kind, and the TOTP seed, as the controller's.
|
||||||
|
- A self-check probe: the away channel satisfies every tier.
|
||||||
|
|
||||||
|
### Modules, after
|
||||||
|
|
||||||
|
- A `telegram` module holding `channel/telegram` and `intake/telegram` (long polling, buttons,
|
||||||
|
`ForceReply` for codes, a linking verb with a one-time deep-link code).
|
||||||
|
- The router keeps `channel/desktop`. An agent bridge consumes intake `message`s. The dead-man ping
|
||||||
|
goes in the self-check and in the watcher.
|
||||||
|
|
||||||
|
## The two tables
|
||||||
|
|
||||||
|
### Decisions × the capabilities they require
|
||||||
|
|
||||||
|
| Decision | Tier | choice | reply | verified-sender | exact-render | second-factor |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| `conditions silence` | acknowledge | ✓ | | ✓ | ✓ | |
|
||||||
|
| `retire approve` / `reject` (exact set) | approve | ✓ | | ✓ | ✓ | |
|
||||||
|
| confirm a binding move (`pin` for `binding-kept`) | approve | ✓ | | ✓ | ✓ | |
|
||||||
|
| `plans stop` / `close` | approve | ✓ | | ✓ | ✓ | |
|
||||||
|
| `push` by hand | approve | ✓ | | ✓ | ✓ | |
|
||||||
|
| `broker consumer-reset` | approve | ✓ | | ✓ | ✓ | |
|
||||||
|
| a healer's repair once more | approve | ✓ | | ✓ | ✓ | |
|
||||||
|
| `cleanup delete` (one, or an exact listed set) | destroy | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||||
|
| operator identities, away channel, factor enrolment | destroy | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||||
|
|
||||||
|
### Surfaces × the capabilities they declare
|
||||||
|
|
||||||
|
| Surface | deliver | reaches-away | loud | silent | edit | choice | reply | verified-sender | exact-render | second-factor | private | reaches-when-mesh-down | Can decide |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| telegram | ✓ | ✓ | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | | all tiers |
|
||||||
|
| desktop (dunst) | ✓ | | ✓ | ✓ | ✓ | | | | ✓ | | ✓ | | none, shows "decide on telegram" |
|
||||||
|
| ntfy | ✓ | ✓ | ✓ | ✓ | | | | | ✓ | | ~ | ~ (ntfy.sh) | none |
|
||||||
|
| matrix (own server) | ✓ | ~ | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ~ | | all tiers, once push is measured |
|
||||||
|
| pushover | ✓ | ✓ | ✓ | ✓ | | ~ (acknowledge) | | ✓ | ✓ | | | ✓ | acknowledge |
|
||||||
|
| mail (outside relay) | ✓ | ✓ | | ✓ | | | ✓ | | ✓ | ~ | | ✓ | none (break-glass with a code) |
|
||||||
|
| agent in a terminal | — | | | | | | ~ (relayed) | | | | | | none: requests only |
|
||||||
|
| agent through telegram | via the telegram holder | | | | | ✓ (holder's buttons) | ✓ | ✓ (holder's) | ✓ (holder renders) | ✓ | | | all tiers, in the same chat |
|
||||||
|
| console (a shell) | — | | | | | | | | ✓ (own output) | ✓ | | | break-glass with a code, recorded |
|
||||||
|
| the watcher's sender | ✓ | ✓ | | | | | | | | | | ✓ | none; outside the seats |
|
||||||
|
|
||||||
|
## The proposed record
|
||||||
|
|
||||||
|
> **Title.** Channels and intake are seats with declared capabilities, decisions are the controller's,
|
||||||
|
> and Telegram is their first holder.
|
||||||
|
>
|
||||||
|
> **Context.** The output channel was built in its minimal form (ADR 0227, to-be 45 §5): one holder
|
||||||
|
> carrying the router, a Telegram client and a desktop adapter, and no answering back. The operator
|
||||||
|
> expects many channels and inputs. The operator requires approving and rejecting from the away
|
||||||
|
> channel, and requires that nothing force a desk while working through it. Decisions today are verbs
|
||||||
|
> any granted principal can call, agents included, and a hand-act records the calling principal, not
|
||||||
|
> the person.
|
||||||
|
>
|
||||||
|
> **Considered options.**
|
||||||
|
> 1. Channels as contributions to the output seat (028 Q1 a). A channel is running code with its own
|
||||||
|
> secret and answers, not content a holder places.
|
||||||
|
> 2. One seat per channel kind. The router learns every seat.
|
||||||
|
> 3. Channel modules found by a manifest field. Callers use seats, never modules (ADR 0126).
|
||||||
|
> 4. One notifier with every channel built in. Shared failure, shared secrets, a release per channel.
|
||||||
|
> 5. A Telegram-specific module with its own approval path. Locks decisions to one service, and the
|
||||||
|
> next channel repeats it.
|
||||||
|
> 6. **Kinded benches `channel` and `intake`, a capability vocabulary, and decisions held by the
|
||||||
|
> controller. Chosen.**
|
||||||
|
>
|
||||||
|
> For answering:
|
||||||
|
> - a webhook (needs an inbound route; a stolen token redirects it) or **long polling (chosen)**;
|
||||||
|
> - decisions as direct verb calls by the channel module (the module decides who the operator is, and
|
||||||
|
> the record names the module) or **request and answer through the controller (chosen)**.
|
||||||
|
>
|
||||||
|
> **Decision.**
|
||||||
|
> - Two mesh seats are kinded benches: `channel` (send, edit, reply, standing) and `intake` (one
|
||||||
|
> envelope per input, emitted on the seat). Each holder is its own module, claims one kind, and
|
||||||
|
> declares capabilities from `channel-capabilities/1`. Each capability has a contract test and a
|
||||||
|
> drill, and a failed drill withdraws it.
|
||||||
|
> - The output seat's holder routes by required capability, verified presence and severity. It says
|
||||||
|
> when nothing can carry a message, and never degrades a decision.
|
||||||
|
> - The controller holds decisions: `decide request` (anyone; never performs), `decide answer` (intake
|
||||||
|
> holders only), `decisions`. It checks the caller's declared capabilities from its own records, the
|
||||||
|
> sender against its own list of the operator's identities, the exact state's digest, single use and
|
||||||
|
> expiry. It verifies destroy codes itself. It records `via`, `requested-by`, `decision` and `factor`
|
||||||
|
> on the hand-act.
|
||||||
|
> - Decisions are tiered acknowledge, approve and destroy, with the requirements in the table. The
|
||||||
|
> operator's away channel must satisfy every tier, checked by the self-check, unless the operator
|
||||||
|
> chose otherwise for a tier.
|
||||||
|
> - No agent decides. Decision-bearing verbs refuse direct calls except as break-glass with a code.
|
||||||
|
> - Telegram is the first holder of both seats and the away channel. The watcher's watcher stays
|
||||||
|
> outside the seats with its own bot. An outside dead-man service is pinged by the self-check and
|
||||||
|
> the watcher.
|
||||||
|
>
|
||||||
|
> **Consequences.**
|
||||||
|
> - The output seat's holder loses its Telegram client to a module of its own.
|
||||||
|
> - The catalogue gains `kind` and `capabilities` on a claim, and a second kind of bench.
|
||||||
|
> - The controller's verb definition gains `decision`.
|
||||||
|
> - `retire approve` takes the set it approves.
|
||||||
|
> - Agents' grants lose decision-bearing verbs.
|
||||||
|
> - To-be 45 §5 is amended: answering back exists.
|
||||||
|
> - Telegram sees the words of decisions, held to the content rule. It never sees a code's seed.
|
||||||
|
>
|
||||||
|
> **How it is checked.**
|
||||||
|
> - Catalogue tests: a claim with an unknown capability is refused; two holders of one kind are
|
||||||
|
> refused; each declared capability's contract test runs in the holder's build.
|
||||||
|
> - Controller tests, one per refusal:
|
||||||
|
> - a `decide answer` from a non-intake principal;
|
||||||
|
> - from a kind whose declared capabilities do not meet the tier;
|
||||||
|
> - from an identity not on the list;
|
||||||
|
> - with a stale state digest;
|
||||||
|
> - a second answer to one decision;
|
||||||
|
> - a destroy without a valid, unused code;
|
||||||
|
> - a direct `retire approve` without a code.
|
||||||
|
> - Self-check probe: the away channel meets every tier.
|
||||||
|
> - Live drill: request an approve and a destroy decision on a test condition, answer both on the phone,
|
||||||
|
> and read the hand-acts.
|
||||||
Reference in New Issue
Block a user