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..2780521 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,34 @@ 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 conversation** (widened 2026-10-06): the mesh, its modules and its agents send messages and + **asks** (a question, a choice, a value, an approval), and the operator answers or writes first, over + channels chosen by their declared **capabilities** and by the operator's **work context**. Inputs + from outside (a mail arriving) share the same envelope; +- **asks that authorise:** one layer on top, for the answers that perform an action. These are checked + by the controller, and allowed only on channels whose capabilities prove that the operator answered. + +### Why this effort widened rather than a new one opened + +On 2026-10-06 the operator asked for these: +- approving and rejecting through Telegram; +- a generic shape in which Telegram simply holds a seat; +- input triggers alongside output channels; +- capabilities that decide which actions may travel on which channel; +- the work context as a factor in choosing the channel; +- asks that are not about permission at all. + +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; +- an answer belongs to the message or ask this seat sends. Splitting them 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 +104,19 @@ 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. [A conversation with the operator](06-a-conversation-with-the-operator.md): messages, asks and + operator messages; asks' kinds and life; the kinded benches `channel` and `intake`; the capability + vocabulary; agents as participants; the migration. +7. [The work context, and the desk](07-the-work-context-and-the-desk.md): the signals, the routing + by context and its escalation, presence kept inside the mesh, and the desk as a full participant + (notification actions, the launcher's prompt). +8. [Asks that authorise](08-asks-that-authorise.md): the actions that need a person, the trust + capabilities, the three proofs and the three tiers, the controller's checks, why the desk needs a + factor, and what a compromise can reach. +9. [A proposed decision](09-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..1d22157 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-a-conversation-with-the-operator.md) and [08](08-asks-that-authorise.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..b21bc04 --- /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-a-conversation-with-the-operator.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 + ([08](08-asks-that-authorise.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 ([08](08-asks-that-authorise.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. [08](08-asks-that-authorise.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..102b6b9 --- /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-a-conversation-with-the-operator.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 answer through it, and +authorise an action through it?** ([08](08-asks-that-authorise.md)). + +Two roles are judged separately, because they want different things: + +- **The away channel:** the mesh's urgent messages and its asks, 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-a-conversation-with-the-operator.md)), whatever its weakness as a channel for asks that authorise. + +### 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-a-conversation-with-the-operator.md). ✓ declared honestly, +— not, ~ conditional (the note says on what). + +| Holder | reaches-away | loud | silent | edit | choice | reply | verified-sender | exact-render | code-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 asks,** 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 authorising ask ([08](08-asks-that-authorise.md)), including a code as a second proof. Its price is that +Telegram reads the words, which is what the content rule is for. +- **Matrix** is the self-hosted equivalent for asks. 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 an answer 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-a-conversation-with-the-operator.md b/01-RESEARCH/028-the-meshs-output-channel/06-a-conversation-with-the-operator.md new file mode 100644 index 0000000..2fa0a45 --- /dev/null +++ b/01-RESEARCH/028-the-meshs-output-channel/06-a-conversation-with-the-operator.md @@ -0,0 +1,332 @@ +# 06 — A conversation with the operator + +The operator's directions, 2026-10-06, in substance: + +- **Telegram is one 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. +- **Each channel has capabilities.** +- **Approval is only an example.** An agent may just as well want to ask a simple question, and "it + doesn't have to be permission related". + +So the core of this effort is not a notifier and not an approval path. It is **a conversation with +the operator, held over channels**: + +- the mesh, its modules and its agents **say** things and **ask** things; +- the operator **answers**, or **writes first**; +- each exchange goes over whichever channel is right for its needs and for where the operator is. + +This document is that general model. [07](07-the-work-context-and-the-desk.md) is how the work context +chooses the channel. [08](08-asks-that-authorise.md) is one layer on top: asks whose answer performs an +action, and the checks that makes necessary. [04](04-telegram-as-the-first-holder.md) and +[05](05-the-other-holders-on-the-same-axes.md) are the first holders. + +## 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 through the `node-notifier` + seat's `send`. + - The router, the Telegram client and the desktop adapter are one process. + - `notify` is a served verb, not the work queue to-be 32 §3 sketched. +- **The desktop notifier** is the node seat `node-notifier`, held by the dunst module on each + graphical machine. +- **The watcher's watcher** has its own Telegram client and token, and is not assigned yet. +- **Nothing reads anything back.** The operator speaks to the mesh only through an agent session or a + shell. + +## The three things said + +- **A message:** the mesh tells the operator something. A condition raised, a reminder, a clearing, + a notice from a module. It expects no answer. It may be edited later (a clearing). +- **An ask:** someone wants the operator's input, of a declared kind. The answer goes back to whoever + asked. +- **An operator message:** the operator writes first. It is a message to an agent, a note to the mesh, + or an answer to an ask written in the thread instead of tapped. + +Inputs from outside that are not the operator (a mail arriving, a webhook) are the same kind of +envelope as an operator message, with a sender that is not the operator. This effort designs their +shape only. Their consumers are later work. + +## Asks + +### Kinds + +| Kind | The operator gives | Required capabilities of the channel | +|---|---|---| +| `yes-no` | yes or no | `choice`, or `reply` (read as yes or no) | +| `one-of` | one of up to eight labelled options | `choice`, or `reply` with the option's number | +| `text` | free text | `reply` | +| `number`, `date` | a value of that type, within bounds | `reply`. Parsed and checked by the seat's holder; asked again once if it does not parse. | +| `acknowledge` | "seen" | `choice` | + +An ask whose answer **performs an action** (approve a retirement, delete data) is the same ask with +an **authorising** flag. It adds requirements of trust, which [08](08-asks-that-authorise.md) +defines. Everything else about it is as below. + +### What an ask carries + +- an id; +- the asker (its bus principal and its machine); +- the kind, with its options or bounds; +- the words; +- a priority (urgent or normal); +- optionally a timeout and a default; +- optionally a conversation handle (where the asker is talking with the operator); +- optionally a group, for batching. + +The words pass the content rule like every message. + +### Its life + +**open → answered | defaulted | expired | cancelled** + +- **Answered:** the first answer wins. Copies of the ask shown on other channels are edited to say + where it was answered. +- **Defaulted:** the timeout passed and the ask declared a default. The asker receives the default, + marked as a default, never as the operator's answer. +- **Expired:** the timeout passed with no default. The asker is told. + - **An authorising ask never defaults to performing.** It expires. ADR 0230's rule, "a timer is the + mesh acting alone again, only later", applies to every ask that authorises. +- **Cancelled:** the asker no longer needs it (`ask cancel`), or its owner sees it is moot (the + condition behind it cleared). Its copies are edited to "no longer needed". + +### How the asker gets the answer + +- The answer is emitted as **`ask-answered`**, with the ask's id and, where there is one, the + conversation handle. +- An asker may **wait**: `ask` with a wait of up to a few minutes returns the answer if it comes in + time, and otherwise returns the id. +- An agent working through a long task can **poll** `asks `. + +### Batching + +- **Asks to the same channel within the burst window go out together.** A heading says how many are + open ("3 questions waiting"), and each ask follows as its own message, so each can be answered and + edited alone. +- **An asker may hold at most three open asks.** A fourth is refused to it, in words, so an agent in + a loop cannot flood the operator. +- **Asks count against the router's hourly cap** like messages. Answers do not. + +### History + +**`asks`** lists open asks, and closed ones for 30 days: the asker, the kind, the outcome, the channel, +and the answer. A free-text answer is the operator's own words. It stays in the router's state and is +never forwarded to a channel other than the one it came from. + +## Who holds what + +### The output seat's holder becomes the conversation's router + +It holds `operator-channel` and gains asks: + +- `ask` (create), +- `ask cancel`, +- `asks`, +- `answer` (called by intake holders, below), +- events `ask-opened`, `ask-answered`, `ask-closed`. + +It **owns** every ask that authorises nothing. + +An authorising ask is **owned by the controller** ([08](08-asks-that-authorise.md)). The router carries +it like any other, but its answer goes to the controller, which alone can perform. + +### 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 assumed +each channel attaches by contribution. With many channels to come, and channels that answer, that is +the question to settle. + +The mesh's 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 (design 33 §4). +- A **bench** is a seat with several holders. 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 (ADR 0190). +- A **contribution** is content another module hands to a seat's holder (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 holder places. + - A channel is running code: it holds a secret, keeps a connection, reads answers, fails on its own. + - Making it fit puts every channel's client back in the router. **Rejected.** +- **b. One seat per channel kind.** The router learns every seat; a new channel is a change to the + router. **Rejected.** +- **c. Channel modules found by a manifest field and called by module address.** Callers use seats, + never modules (ADR 0126). **Rejected.** +- **d. One monolithic notifier,** every channel built into the router. Shared secrets and failures, a + release per channel. **Rejected.** +- **e. A channel module per service, with its own approval or question path** (a "Telegram module" + that decides things). It locks the conversation to one service, and the next channel repeats it. + **Rejected.** +- **f. Kinded benches.** Two mesh seats, **`channel`** (out) and **`intake`** (in). Their holders are + different modules, each claiming a **kind** (`telegram`, `desktop`, `ntfy`, `matrix`, `mail`, …). + - One holder per kind; two claiming one kind is refused at registration. + - A verb's subject carries the kind, as a node seat's carries the machine: + `mesh.seat.channel.tool.send.`. + - A new channel is a new module claiming a new kind, with no change to the router. + - **Chosen.** + +Option f needs a second bench, of a new sort (different modules, keyed by kind), which ADR 0223 says +must be decided. It also needs a claim that carries a kind and capabilities. + +## The channel seat (out) + +**`channel`**, mesh scope, a kinded bench. + +### Served + +- **`send`:** words, a priority, whether silent, an optional **ask block** (the kind, the options each + with an opaque token, the ask id) and an optional thread (the conversation handle, or the message + this replies to). Answers the channel's id for what it sent, or a refusal in words. +- **`edit`:** replace a sent message by its id, where `edit` is declared. +- **`standing`:** ready, not configured (naming what is missing, never a value), or failing (since + when, why); the last delivery; the declared capabilities. + +### Emitted + +- **`delivered`** and **`failed`**, the latter marked **permanent** or **transient** (defect D6 in + [04](04-telegram-as-the-first-holder.md)). + +### Honoured + +- The declared maximum length, by cutting and saying so (D1). +- Silence where declared (D4). +- An identical edit is a success (D6). +- No own secret in any error or event. + +### The content rule + +The content rule is the router's, applied before anything reaches a holder that does not declare +`private`. + +## The intake seat (in) + +**`intake`**, mesh scope, a kinded bench. A service that is read and written by one program (a +Telegram bot, [04](04-telegram-as-the-first-holder.md)) is held by one module claiming both seats under +one kind. + +A holder turns what arrives into **one envelope**: + +| Field | What it is | +|---|---| +| `id` | Unique, for deduplication. | +| `kind` | The holder's kind. | +| `what` | `message` (written first), `choice` (a button or a reaction), `reply` (written in an ask's thread), `mail`, `call` (a webhook), `seen` (activity, for the work context). | +| `sender` | The identity on that service, and whether the service authenticated it. | +| `operator` | Whether that identity is on the **controller's** list of the operator's identities. Filled from that list, never from the holder's own. | +| `conversation` | An opaque handle. Sending on it reaches the same chat, room or mail thread. | +| `in-reply-to` | The ask or message it answers, if any. | +| `payload` | The text, or the option's token. **No secrets:** a code for an authorising ask never travels in an envelope ([08](08-asks-that-authorise.md)). | +| `at` | When. | + +**Answers go to the ask's owner by request and reply:** +- the router's `answer` for an ordinary ask; +- the controller's for an authorising one. + +**Everything else is an event on the seat,** which consumers take by `what`: +- the router takes `seen` for the work context; +- an agent bridge takes `message` from the operator; +- a future mail rule takes `mail`. + +**One gap to close:** the shared library cannot yet publish on a seat's event subjects (design 32 §1). + +## The capability vocabulary + +**Fixed and versioned:** `channel-capabilities/1`. A holder declares capabilities in its claim, and +the catalogue refuses a word outside the vocabulary. **Each capability has a contract test** the +holder's build runs and a **drill** its `standing` can run. One that fails its drill is reported and +withdrawn from routing until it passes. + +The words are in three groups. The first two serve every conversation. The third exists only for +asks that authorise, and is defined in [08](08-asks-that-authorise.md). + +### Delivering + +| Capability | Promise | Test / drill | +|---|---|---| +| `deliver` | It arrives, or `failed` says why. | Against a test double; a live test message. | +| `reaches-away` | It reaches a phone away from the operator's machines. | Declared by kind; the operator acknowledges a drill. | +| `loud` | It can break through the phone's quiet hours. | The service's override is set for urgent. | +| `silent` | It can arrive without sound. | The service's silent flag is set. | +| `edit` | A sent message can be replaced in place. | Edit and read back, against a double. | +| `max-length:N` | Up to N characters arrive whole; longer is 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. | Checked by the holder's placement and its send path. | +| `private` | The words stay on the operator's machines, or are end-to-end encrypted. | Declared by kind; reviewed. | + +### Conversing + +| Capability | Promise | Test / drill | +|---|---|---| +| `choice` | The operator can pick one offered option in one act, and the pick comes back. | A simulated tap gives a `choice` envelope with the option's token. | +| `reply` | The operator can answer in free text, and it comes back. | A simulated reply gives a `reply` envelope. | +| `threads` | An answer is tied to the message it answers. | A reply to message A carries A in `in-reply-to`. | +| `operator-first` | The operator can write to the mesh unprompted. | A simulated message gives a `message` envelope. | + +### Trusting + +`verified-sender`, `exact-render`, `code-factor` and `key-factor` are defined in +[08](08-asks-that-authorise.md). A channel without them still converses fully. It just cannot carry +an answer that performs an action. + +## Agents in the conversation + +An agent is a participant. It asks, and it receives. + +- **An agent whose operator is at its own terminal** asks there, in the terminal. The seat is not + needed. +- **An agent working unattended** (in the background, on a schedule, or with the operator stepped + away) asks **through the seat**. The router puts the ask where the operator is now + ([07](07-the-work-context-and-the-desk.md)): the desk if they are at it, Telegram if they are away + or talking through Telegram. The agent waits for, or polls, the answer. +- **An agent the operator talks to through Telegram** receives the operator's messages as intake + envelopes. It answers on the conversation handle. Its asks carry that handle, so they appear in the + same chat. +- **An agent's words reach the operator only as an asker's words,** and the operator's answer reaches + the agent only as the operator's. An agent relaying "the operator said yes" is not an answer to + anything. That is why an answer comes from a channel holder, never from the asker + ([08](08-asks-that-authorise.md)). + +## The watcher, in this shape + +The watcher's watcher stays **outside** the seats, deliberately: +- it must speak when the bus and the control node are what failed; +- the seats live on the bus. + +It is a minimal `deliver` + `reaches-when-mesh-down` sender with its own bot. It never reads, and never +asks. Its sibling outside the mesh is the dead-man service ([05](05-the-other-holders-on-the-same-axes.md)). + +## From today to this shape + +1. **Fix the first holder in place:** D1–D4 of [04](04-telegram-as-the-first-holder.md). No change of + shape. +2. **The operator configures Telegram** ([09](09-a-proposed-decision.md)). The mesh starts telling. +3. **The vocabulary and the kinded bench in the catalogue,** and seat events in the shared library. +4. **Split Telegram out** into its own module holding `channel` and `intake` under `telegram`. The + router keeps the desktop adapter as `channel/desktop` and `intake/desktop`. +5. **Asks in the router,** with buttons and replies on Telegram and actions on the desktop + ([07](07-the-work-context-and-the-desk.md)). Agents can ask from here on. +6. **The work context** as the router's ordering ([07](07-the-work-context-and-the-desk.md)). +7. **Authorising asks in the controller** ([08](08-asks-that-authorise.md)). +8. **Operator-first messages,** and an agent bridge consuming them. +9. **Further holders as wanted:** + - Pushover for waking; + - Matrix once its push is measured; + - mail out 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:** channels attach as holders of kinded benches (f), not as contributions. +- **Q3:** the life of a message gains the life of an ask. `edit` and `silent` are declared, and decide + how a clearing is said (D2, D3). +- **Q4:** presence becomes the work context ([07](07-the-work-context-and-the-desk.md)). +- **Q7:** answering back is the conversation itself. Its authorising layer is + [08](08-asks-that-authorise.md). +- **Q8:** the content rule stays, applied by the router. Whether a `private` holder may be exempted + is left to graduation. diff --git a/01-RESEARCH/028-the-meshs-output-channel/07-the-work-context-and-the-desk.md b/01-RESEARCH/028-the-meshs-output-channel/07-the-work-context-and-the-desk.md new file mode 100644 index 0000000..c46219f --- /dev/null +++ b/01-RESEARCH/028-the-meshs-output-channel/07-the-work-context-and-the-desk.md @@ -0,0 +1,117 @@ +# 07 — The work context, and the desk + +The operator, 2026-10-06: + +- "If dunst can also show buttons, we could prefer to use desktop notifications instead of Telegram + for approval actions when working in a session." +- "The work context is an important factor when deciding the correct output channel." + +[06](06-a-conversation-with-the-operator.md) decides **which channels may carry** a message or an ask: +those whose declared capabilities satisfy it. This document decides **which of those comes first**, +from where the operator is working. It also makes the desk a full participant in the conversation. + +## The rule + +> **A message or ask goes to the most direct channel in the operator's current context, among those +> whose capabilities already satisfy it. Unanswered in time, it escalates along a fixed chain. +> Context orders the candidates; it never adds one. Context never lowers the bar.** + +The last sentence matters most for asks that authorise ([08](08-asks-that-authorise.md)). Being at the +desk never makes a click count as more than it proves. + +## The signals + +| Signal | Source | Read as | +|---|---|---| +| A graphical session unlocked, with input in the last 5 minutes, on machine M | The `node-lock-screen` seat's holder on M (the screen-lock module), whose tools already read the idle time and the lock state. Underneath: logind's `LockedHint` and `IdleSinceHint`. Proposed: an event on each change, not a poll. | **at the desk on M** | +| That session locked, or idle longer | the same | **not at the desk** | +| An agent asking from machine M | The ask's asker names its machine. An agent module's own "session active" event, when one exists. | **working with an agent on M**. It strengthens "at the desk on M"; alone it proves nothing. | +| A verified intake `message`, `reply` or `choice` in the last 15 minutes | The intake seat ([06](06-a-conversation-with-the-operator.md)) | **in a conversation** on that kind | +| An ask carrying a conversation handle | The ask | **that conversation**, whatever else is true | +| The hour, against quiet hours | The router's setting | **night**: only urgent wakes | + +## The contexts, and where things go + +"The away channel" is the operator's setting (Telegram, to begin with). "The loud holder" is an +optional second away holder for waking (Pushover, [05](05-the-other-holders-on-the-same-axes.md)). + +| Context | An ask goes to | Urgent message | Warning | Unanswered or unacknowledged → | +|---|---|---|---|---| +| **In a conversation through Telegram** (the ask carries its handle, or Telegram activity is newer than any desk input) | that chat, in the thread | that chat | that chat, silent | after 10 min (urgent) or 1 h: also the desk, if active | +| **At the desk on M** (with or without an agent there) | the desk on M, if it can carry the ask; otherwise the away channel, and the desk says where it went | the desk on M, and the away channel silently | the desk on M | after 5 min (urgent) or 30 min: the away channel, with sound | +| **Away** (no unlocked active session, no recent conversation) | the away channel | the away channel | the away channel, silent | after 15 min (urgent): the loud holder, if configured | +| **Night, away** | non-urgent asks wait for the morning; urgent as away | the away channel and the loud holder | the morning digest | as away | +| **The desk locks while an ask is shown there** | moves at once to the away channel | — | — | — | + +- **Every copy of an ask stays valid until one answer wins.** The others are edited to say where it + was answered. +- **The context's channel cannot carry the ask** (a free-text ask at a desk without a prompt, or an + authorising ask the desk cannot prove): the next in the chain carries it, and the context's channel + says where it went. +- **Nothing can carry it:** the router says so, as a condition of its own. + +### Presence stays in the mesh + +- **Presence facts are events on the bus,** consumed by the router. +- **They are kept as current state only:** a key-value entry per machine and per intake kind, + overwritten, never a history. +- **They never appear in a message's words,** so they never reach a channel that is not `private`. +- **No module keeps them as a timeline of the operator's day.** A consumer that wants one is a + decision of its own. + +## The desk as a participant + +Read from the catalogue's main branch and the tools' current documentation, 2026-10-06. + +### What exists + +- **The `node-notifier` seat** is held on each graphical machine by the dunst module. Its `send` runs + `notify-send --print-id` with an urgency, an application name and an optional replace id. It + carries **no actions** today. +- **libnotify's `notify-send`** (0.8 and later) takes `--action=NAME=Label`, repeatable, and `--wait`. + It prints the chosen action's name when one is chosen, and nothing when the notification is closed. + Underneath, the notification server emits `ActionInvoked` with the notification's id and the + action's key. +- **dunst shows actions:** + - `do_action`, which the module binds to the **middle** click, invokes the default or only action; + - otherwise it opens the **context menu**, which in this mesh is the `node-launcher` seat's + dmenu-compatible menu; + - `dunstctl action` and `dunstctl context` do the same from a command line. +- **The `node-launcher` seat's `menu` verb** shows a list in the operator's session and answers the + chosen line. A dmenu-compatible menu also accepts typed text that is not a listed line, which makes + it a free-text prompt. +- **The graphical session is X11.** + +### What it takes + +- **`send` gains actions:** a list of (token, label). +- **It still answers at once.** A notification may be answered minutes later. +- **The holder listens for `ActionInvoked`** and emits the chosen token as an event on its node seat. +- **The router's desktop adapter,** which holds `channel/desktop` and `intake/desktop`, turns that + into a `choice` envelope. +- **For `text`, `number` and `date` asks,** the notification's single action opens the launcher's + prompt, and what is typed comes back as a `reply`. + +### What the desk can declare + +| Group | Capabilities | +|---|---| +| Delivering | `deliver`, `silent` (low urgency), `loud` (critical urgency stays until dismissed), `edit` (replace id), `private` | +| Conversing | `choice` (actions), `reply` (through the launcher's prompt), `threads` (the ask's id is carried) | +| Trusting | **not** `verified-sender`. `exact-render` yes. `code-factor` (a prompt) yes. `key-factor` yes where a security key is plugged in. See [08](08-asks-that-authorise.md). | + +So the desk carries **every ordinary ask**: yes or no, one of, text, number, date and acknowledge. +It needs no account anywhere. An agent working unattended on the workstation asks a clarifying question, +and the operator, at the desk, answers it in the notification. + +It carries an **authorising** ask only with a factor the controller verifies itself, because a click +on an X11 desk proves that someone was there, not that the operator clicked +([08](08-asks-that-authorise.md)). + +## Sources + +- `notify-send(1)`, `--action` and `--wait`: https://man.archlinux.org/man/notify-send.1.en +- Desktop notifications and actions: https://wiki.archlinux.org/title/Desktop_notifications +- dunst documentation (mouse actions, `do_action`, the context menu): https://dunst-project.org/documentation/ +- logind's `LockedHint`, `IdleHint`, `IdleSinceHint`: + https://freedesktop.org/software/systemd/man/org.freedesktop.login1.html diff --git a/01-RESEARCH/028-the-meshs-output-channel/08-asks-that-authorise.md b/01-RESEARCH/028-the-meshs-output-channel/08-asks-that-authorise.md new file mode 100644 index 0000000..ca99579 --- /dev/null +++ b/01-RESEARCH/028-the-meshs-output-channel/08-asks-that-authorise.md @@ -0,0 +1,247 @@ +# 08 — Asks that authorise + +Most asks inform their asker and change nothing ([06](06-a-conversation-with-the-operator.md)). Some +answers **perform an action**: approving a retirement, confirming that a binding moves, deleting data. +This document is the layer those asks need on top of the conversation. It is the controller's checks, +the trust a channel must prove, and what a compromise can reach. + +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 is acceptable. +- But when talking to an agent **through** Telegram, the operator cannot switch to a desktop session. +- At the desk, desktop buttons would be preferred ([07](07-the-work-context-and-the-desk.md)). + +## Where this stands against what was decided + +- To-be 45 §5 says: "No answering back in this form". +- ADR 0227 kept it 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 this answers 028's Q7. It does not reverse ADR 0227. + +## What there is to authorise + +Read from the controller's main branch on 2026-10-06. The condition store marks conditions only a +person resolves (`resolver: operator`): from the start for retirement, clean-up and binding conditions, +and once a healer's budget is spent. + +| Action | 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 | 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. The data, not by the mesh. | approve | +| End a stuck plan | `plans stop` / `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 skipped or redelivered | approve (graduation to confirm) | +| Try a healer's repair once more | the healer's ordinary path | any escalation (H1–H5), `healers-braked` | as the repair is | approve | +| Silence a condition | `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` (after 30 days) | **no** | destroy | +| Delete everything retired longer than N days | `cleanup delete --older-than N --confirm --why` | `cleanup-waiting` | **no** | destroy | +| Change the operator's identities, the away channel, or a factor's enrolment | (new) | — | yes, but it changes who may authorise | destroy | + +The build queue verbs and `replay --register` are not offered as asks: no condition asks for them. + +## Trust, as capabilities and proofs + +The conversation's vocabulary ([06](06-a-conversation-with-the-operator.md)) gains four words used +only here: + +| Capability | Promise | Test / drill | +|---|---|---| +| `verified-sender` | The holder proves the answer came from the operator's own account on that service, by the service's authentication, through a holder **no agent shares**: not on the operator's account, not on a machine where agents run as the operator. | A choice from an identity not on the list is dropped and reported. The holder's placement is checked. | +| `exact-render` | The ask is shown as the controller rendered it, by the holder itself. No asker or agent composes the words the operator authorises. | Rendered text equals the controller's, byte for byte, against a double. | +| `code-factor` | The holder can carry a code the operator types to the controller, by request and reply, and never judges it. | A code is never in an event, and is deleted from the conversation where the service allows. | +| `key-factor` | The holder can run a security key's assertion over the controller's challenge, and hand the controller the result. | The challenge is the controller's, and the signature is checked by the controller. | + +From these, three **proofs** that the operator is the one answering: + +- **P1, a verified sender:** a Telegram tap, through a holder on a machine no agent runs on. +- **P2, a code:** from the operator's authenticator, verified by the controller. +- **P3, a key touch bound to the ask:** the controller's challenge is a hash of the ask's id and the + state digest. A FIDO2 assertion with user presence (the key waits for a touch) is checked against the + operator's enrolled credential. It proves a physical touch for **this** ask and no other. + +## Three tiers + +| Tier | Required | +|---|---| +| **acknowledge** | `choice`, `exact-render`. Silencing is open to agents already, and announced; a proof adds nothing. | +| **approve** | `choice`, `exact-render`, and **one** proof (P1, P2 or P3). Single use, bound to the exact state shown, expiring when that state changes or after 24 h. | +| **destroy** | `choice`, `exact-render`, and **two** proofs, at least one of them P2 or P3. Valid 10 minutes after it is shown; at most one destroy answered per 10 minutes. | + +| Where the operator answers | Proofs it offers | acknowledge | approve | destroy | +|---|---|---|---|---| +| Telegram | P1 (tap), P2 (code as a reply) | tap | tap | tap and code | +| the desk, with a security key | P3 (touch), P2 (code in a prompt) | click | click and touch | click, touch and code | +| the desk, without a key | P2 (code in a prompt) | click | click and code | not possible: carried by the away channel | +| an agent's terminal | none | none | none | none: an agent asks, it never answers | +| the console (a shell) | P2 (code) | — | break-glass: a code | none | + +### The rule + +1. **Every authorising verb declares its tier** in the controller's verb table. +2. **An authorising ask is offered only on a channel whose capabilities satisfy its tier.** An answer + arriving from any other channel is refused, and the refusal is said there. +3. **The operator's away channel must satisfy every tier.** The self-check verifies it. A setting + that would make a tier possible only at a desk is refused, unless the operator chose that for the + tier explicitly. While working through Telegram, everything can be completed in Telegram. +4. **The work context chooses among the channels that qualify; it never makes one qualify** + ([07](07-the-work-context-and-the-desk.md)). +5. **No agent authorises.** An agent asks. It holds no verb that performs an authorising action. The + record names the agent that asked. + +## Why the desk needs a factor + +- **X11 does not isolate the clients of one display.** Any of them can inject input (the XTEST + extension, as `xdotool` does) and read keystrokes. +- **`dunstctl action` invokes a notification's action** for any program of the account. +- **The desktop holder runs as the operator's account,** whose files, the holder's bus credential + included, every agent on that account can read. + +So a click at the desk, its `ActionInvoked` and the desktop holder's envelope can all be produced by +an agent. An unlocked session with recent input proves a person was there, not that the person +clicked. The desk declares no `verified-sender`. A factor the **controller** verifies gets around +that. + +| Factor at the desk | Can an agent on the account fake it? | Judgement | +|---|---|---| +| **A security key's touch, bound to the ask** (P3) | No: the touch is physical, and the signature covers this ask's id and state. | **Preferred.** One click, one touch, no phone. Needs a key and an enrolment. | +| **A code typed into the launcher's prompt** (P2) | It cannot know the code. On X11 it can read keystrokes and race to use the code first, and each step's code is accepted once. | **Acceptable** without a key. Costs picking up the phone. The race is a residual risk until the session leaves X11. | +| **The screen's unlock or a fingerprint** | Yes: only the local holder sees the result. | **Rejected.** | + +The desk would earn `verified-sender` only if agents ran under an account of their own, without the +operator's display, session bus or holders' credentials, on a compositor that isolates clients. That +is a question for the agent modules' placement. It is noted, not proposed. + +## The controller holds authorising asks + +Today a grant covers a whole verb (`seat:mesh-controller.retire` allows approving and listing alike). +A hand-act records `by` from the calling bus principal, which for a channel would be the module, not +the person. Both call for the controller to hold these asks itself. + +- **The verb table gains a field.** The controller's verb definition (a name, a description, input and + output schemas) gains **`authorises`**: the tier, and the arguments that make up the exact state a + person must see (for `retire approve`, the set of consumers). + +### Three verbs + +- **`authorise request`:** anyone may call it, an agent or 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 ask: what is asked, the exact state, who asks, what each option does. + - It stores it with an opaque id (10 random base32 characters), its expiry and a digest of the + state shown, and emits `ask-opened` with the controller as owner. + - The router carries it like any ask. **Nothing is performed.** +- **`authorise answer`:** only intake holders are granted it. It carries the id, the option, the + sender's identity, and the code or key assertion where the tier needs them. The controller checks, + and refuses at the first failure: + 1. the ask is open and not expired; + 2. the caller is the holder of an intake kind, by the controller's own seat records, never by the + request's claim; + 3. that kind's declared capabilities, from the controller's records, satisfy the tier, and the + proofs present are enough; + 4. a P1 answer: the sender is on the controller's list of the operator's identities for that kind; + 5. a code: valid for the current or previous 30-second step, and unused; + 6. a key assertion: it verifies against the enrolled credential, over this ask's challenge, with the + user-presence bit set; + 7. the state **now** has the digest it had when shown. Otherwise the ask is void and a new one is + requested. + + Then it performs the action as itself, records the hand-act, closes the ask with a compare-and-set + (so a second answer on another channel loses), and emits `ask-answered`. +- **`authorisations`:** open and recent authorising asks. + +### The authorising verbs refuse to be called directly + +`retire approve|reject`, `cleanup delete`, and `pin` while a `binding-kept` names it refuse a caller +unless the call comes through `authorise answer`, or carries a valid code as **break-glass** at the +console. Break-glass is recorded as such, and announced on every channel. + +- `retire approve` must accept the set it approves (`expect`). Today it re-reads the set when it + runs, so "approve what you were shown" (ADR 0230) does not hold end to end. +- `conditions silence` stays callable. A silence an agent sets is said on the away channel, with its + why. + +### The record + +The hand-act gains: +- **`via`:** the kind and the holder; +- **`requested-by`:** the agent principal or the condition key; +- **`ask`:** the id; +- **`proofs`:** which of P1, P2 and P3 were present. + +`by` reads "the operator, as identity ". Every copy of the ask is edited to the outcome +("approved by the operator on telegram at 14:02 UTC"), and its buttons are removed. + +## Telegram, carrying them + +- **Buttons.** `callback_data` is at most 64 bytes, so a button carries only `a1::