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:
jochen
2026-10-06 16:17:04 +02:00
parent f197fcf146
commit 62db332b3e
7 changed files with 1136 additions and 2 deletions
@@ -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.