From 62db332b3e1da671e2dfd0efd9e97b5fa121780e Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 6 Oct 2026 16:17:04 +0200 Subject: [PATCH] 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. --- .../00-overview.md | 41 ++- .../03-open-questions.md | 3 +- .../04-telegram-as-the-first-holder.md | 222 ++++++++++++++ .../05-the-other-holders-on-the-same-axes.md | 186 ++++++++++++ .../06-channels-and-triggers-as-seats.md | 271 ++++++++++++++++++ .../07-deciding-from-a-channel.md | 226 +++++++++++++++ .../08-a-proposed-decision.md | 189 ++++++++++++ 7 files changed, 1136 insertions(+), 2 deletions(-) create mode 100644 01-RESEARCH/028-the-meshs-output-channel/04-telegram-as-the-first-holder.md create mode 100644 01-RESEARCH/028-the-meshs-output-channel/05-the-other-holders-on-the-same-axes.md create mode 100644 01-RESEARCH/028-the-meshs-output-channel/06-channels-and-triggers-as-seats.md create mode 100644 01-RESEARCH/028-the-meshs-output-channel/07-deciding-from-a-channel.md create mode 100644 01-RESEARCH/028-the-meshs-output-channel/08-a-proposed-decision.md diff --git a/01-RESEARCH/028-the-meshs-output-channel/00-overview.md b/01-RESEARCH/028-the-meshs-output-channel/00-overview.md index b0f90db..9064fd7 100644 --- a/01-RESEARCH/028-the-meshs-output-channel/00-overview.md +++ b/01-RESEARCH/028-the-meshs-output-channel/00-overview.md @@ -10,6 +10,14 @@ touches: - 02-DECISIONS/0208-the-graphical-session-is-one-module-per-piece-on-the-meshs-seats.md - 02-DECISIONS/0210-a-tools-configuration-is-its-seat-holders-and-every-other-module-extends-it-through-the-seat.md - 03-DESIGN/01-to-be/32-what-a-module-declares.md + - 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: [] --- @@ -32,7 +40,27 @@ The effort looks at: - **the life of a message:** deduplicated while it holds, resolved when it stops, acknowledged or silenced by the operator; - **the watcher's watcher:** who tells the operator when the parts that would tell them are the - ones that failed. + 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 @@ -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. 3. [Open questions](03-open-questions.md): the seat, routing, life of a message, the watcher's 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. diff --git a/01-RESEARCH/028-the-meshs-output-channel/03-open-questions.md b/01-RESEARCH/028-the-meshs-output-channel/03-open-questions.md index 4a0ddba..450814b 100644 --- a/01-RESEARCH/028-the-meshs-output-channel/03-open-questions.md +++ b/01-RESEARCH/028-the-meshs-output-channel/03-open-questions.md @@ -1,6 +1,7 @@ # 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 diff --git a/01-RESEARCH/028-the-meshs-output-channel/04-telegram-as-the-first-holder.md b/01-RESEARCH/028-the-meshs-output-channel/04-telegram-as-the-first-holder.md new file mode 100644 index 0000000..f804153 --- /dev/null +++ b/01-RESEARCH/028-the-meshs-output-channel/04-telegram-as-the-first-holder.md @@ -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/?start=`). The operator opens it on the phone; Telegram sends `/start `. + 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) diff --git a/01-RESEARCH/028-the-meshs-output-channel/05-the-other-holders-on-the-same-axes.md b/01-RESEARCH/028-the-meshs-output-channel/05-the-other-holders-on-the-same-axes.md new file mode 100644 index 0000000..c039955 --- /dev/null +++ b/01-RESEARCH/028-the-meshs-output-channel/05-the-other-holders-on-the-same-axes.md @@ -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/ diff --git a/01-RESEARCH/028-the-meshs-output-channel/06-channels-and-triggers-as-seats.md b/01-RESEARCH/028-the-meshs-output-channel/06-channels-and-triggers-as-seats.md new file mode 100644 index 0000000..7c4f22f --- /dev/null +++ b/01-RESEARCH/028-the-meshs-output-channel/06-channels-and-triggers-as-seats.md @@ -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..`, 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.`. + - 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 `) 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..event.`). 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. diff --git a/01-RESEARCH/028-the-meshs-output-channel/07-deciding-from-a-channel.md b/01-RESEARCH/028-the-meshs-output-channel/07-deciding-from-a-channel.md new file mode 100644 index 0000000..abf6883 --- /dev/null +++ b/01-RESEARCH/028-the-meshs-output-channel/07-deciding-from-a-channel.md @@ -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 --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 ` | `binding-kept` (urgent) | the pin, yes (`unpin`). The data, not by the mesh. | approve | +| End a stuck plan | `plans stop` / `plans close --why` | `stalled`, `sent-not-reported` once escalated | no, but it destroys nothing | approve | +| Send a machine its declaration by hand | `push --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 --for --why` | any | yes: it ends by itself, at most 7 days | acknowledge | +| Delete one retired consumer's data | `cleanup delete --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 identity ". 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::