Merge pull request 'Research 028: a conversation with the operator over channels that are seats; Telegram as first holder' (#139) from research/028-telegram-channels-and-triggers into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
This commit was merged in pull request #139.
This commit is contained in:
@@ -10,6 +10,14 @@ touches:
|
||||
- 02-DECISIONS/0208-the-graphical-session-is-one-module-per-piece-on-the-meshs-seats.md
|
||||
- 02-DECISIONS/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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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/<bot>?start=<code>`). The operator opens it on the phone; Telegram sends `/start <code>`.
|
||||
The holder reads it by `getUpdates`, and binds **that** chat and **that** user id only if the code
|
||||
matches and is fresh. This proves the chat belongs to whoever held the code, and it never shows the
|
||||
token to anyone. It needs the holder to read updates, which approvals need anyway
|
||||
([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)
|
||||
@@ -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/
|
||||
@@ -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 <id>`.
|
||||
|
||||
### 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.<kind>`.
|
||||
- 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.
|
||||
@@ -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
|
||||
@@ -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 <node> <provider> --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 <node> <provision> <from> <module>` | `binding-kept` (urgent) | the pin, yes. The data, not by the mesh. | approve |
|
||||
| End a stuck plan | `plans stop` / `close <id> --why` | `stalled`, `sent-not-reported` once escalated | no, but it destroys nothing | approve |
|
||||
| Send a machine its declaration by hand | `push <node> --why` | `sent-not-reported` once escalated | n/a | approve |
|
||||
| Reset a bus consumer's position | `broker consumer-reset --why` | `consumer-behind` once escalated | no: messages 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 <key> --for --why` | any | yes, it ends by itself (at most 7 days) | acknowledge |
|
||||
| Delete one retired consumer's data | `cleanup delete <node> <provider> <consumer> --why` | `cleanup-waiting` (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 <kind> identity <id>". 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:<id>:<option>`.
|
||||
Everything else is in the controller.
|
||||
- **A tap** arrives as a `callback_query` with the tapping user's id and the chat. The holder:
|
||||
1. drops it, and reports, unless both are the operator's;
|
||||
2. calls `answerCallbackQuery` at once, because the phone shows a spinner until it does;
|
||||
3. calls `authorise answer`;
|
||||
4. edits the message to the outcome or the refusal.
|
||||
- **A destroy ask** answers the tap with a `ForceReply` prompt for the code. The holder reads the
|
||||
operator's reply, deletes it from the chat (bots may delete incoming messages in private chats), and
|
||||
hands it to the controller by request and reply. It is never put in an event.
|
||||
- **Long polling, not a webhook.**
|
||||
- `getUpdates` needs no route into the mesh.
|
||||
- A stolen token can steal updates, and a second reader shows as HTTP 409, but it cannot inject an
|
||||
update.
|
||||
- A webhook needs a public route, and a stolen token can redirect it.
|
||||
- The offset is kept in the holder's state, and ids are single use anyway.
|
||||
- **Placement.** The Telegram holder runs where no agent runs as the operator. Its own declaration of
|
||||
`verified-sender` depends on it.
|
||||
|
||||
## Agents
|
||||
|
||||
- **In a terminal:**
|
||||
1. The agent calls `authorise request`.
|
||||
2. The router carries the ask to where the operator is ([07](07-the-work-context-and-the-desk.md)):
|
||||
the desk, with a factor, or Telegram.
|
||||
3. The agent sees `ask-answered`.
|
||||
|
||||
Nothing the agent says counts.
|
||||
- **Through Telegram:** the request carries the conversation handle, so the ask appears in the same
|
||||
chat, rendered by the holder, and the operator taps in place, destroy included.
|
||||
|
||||
## If something is compromised
|
||||
|
||||
| What is lost | What the attacker can do | What limits it |
|
||||
|---|---|---|
|
||||
| The bot token | Read what the bot is sent from then on. Steal taps (visible as 409). Send the operator fake messages. | It cannot call `authorise answer`. Revoke with BotFather's `/token`. |
|
||||
| The operator's Telegram account, on a new device | Approve or acknowledge. | Telegram's two-step password. Every authorisation is announced on the other channels. Approve is reversible. Destroy needs a code. |
|
||||
| The phone, unlocked | Everything, including destroy, if the authenticator is open on it. | An authenticator behind biometrics. One destroy per 10 minutes, announced. Backups (research 030). A setting turning destroy off for the away channel. |
|
||||
| An agent on the operator's account | Click at the desk, read the desk's keystrokes, read the desktop holder's credential. | No tier accepts the desk without a code or a key touch the controller verifies. A code read off X11 is good for one step, and the race is said above. |
|
||||
| A channel module, or its bus account | Forge P1 for approve or acknowledge. | It cannot forge P2 or P3. Destroy needs one of them. |
|
||||
| Telegram itself | Read the words. In principle, forge a tap. | The content rule. Destroy needs P2 or P3, which Telegram never sees. |
|
||||
|
||||
**The factors' secrets are the controller's own.**
|
||||
- The TOTP seed is made by the mesh. It is enrolled by showing its URI once, only to a terminal, and
|
||||
never through a channel or an event.
|
||||
- A security key is enrolled by registering its credential's public key.
|
||||
- Re-enrolling either is a destroy ask.
|
||||
|
||||
## The other holders, for authorising
|
||||
|
||||
- **Matrix** can declare everything Telegram does: reactions, replies, a sender authenticated by the
|
||||
mesh's own homeserver, codes. It is the self-hosted carrier once its push is measured.
|
||||
- **ntfy:** an `http` action makes the phone call the mesh, with a credential inside the notification.
|
||||
Nobody knows who tapped, and there is no reply. No tier.
|
||||
- **Pushover:** acknowledgement, read back by polling a receipt. At most `acknowledge`.
|
||||
- **Mail:** a reply can carry a code, but the sender is forgeable. No tier, except as break-glass.
|
||||
|
||||
## Sources
|
||||
|
||||
As in [04](04-telegram-as-the-first-holder.md) and [07](07-the-work-context-and-the-desk.md), and:
|
||||
- Bot API `callback_data` (1–64 bytes), `answerCallbackQuery`, `ForceReply`, `deleteMessage`:
|
||||
https://core.telegram.org/bots/api
|
||||
- `answerCallbackQuery` is required even with no text: https://gramio.dev/telegram/methods/answercallbackquery
|
||||
- X11 and its clients (input injection, keystroke reading):
|
||||
https://hackindex.io/services/x11/exploitation/x11-session-hijacking and
|
||||
https://www.semicomplete.com/projects/xdotool/
|
||||
- `fido2-assert` (user presence, verifying an assertion):
|
||||
https://developers.yubico.com/libfido2/Manuals/fido2-assert.html
|
||||
- ntfy `http` actions: https://docs.ntfy.sh/publish/
|
||||
- Pushover receipts: https://pushover.net/api
|
||||
@@ -0,0 +1,238 @@
|
||||
# 09 — A proposed decision, and what the operator does now
|
||||
|
||||
This is the effort's reading as of 2026-10-06, written so that playbook 02 can turn it into a record
|
||||
and amend to-be 45 §5. It is a proposal: nothing here is decided until it graduates.
|
||||
|
||||
## The recommendation, short
|
||||
|
||||
1. **The mesh holds a conversation with the operator over channels.** It sends messages and asks;
|
||||
the operator answers or writes first. A message, an ask and an operator message are the three
|
||||
things said ([06](06-a-conversation-with-the-operator.md)).
|
||||
2. **Channels and intake are seats.** One kinded bench each, `channel` and `intake`. Each holder is a
|
||||
module of its own, claiming a kind and declaring capabilities from a fixed, versioned vocabulary,
|
||||
each with a contract test and a drill.
|
||||
3. **Asks are general.** Yes or no, one of, text, number, date, acknowledge, each requiring its own
|
||||
capabilities. They have timeouts, defaults, cancellation, batching and history. The answer returns
|
||||
to the asker as an event, and an asker may wait.
|
||||
4. **The output seat's holder is the router.** It chooses among the channels that satisfy a message or
|
||||
ask, by **work context**: in a Telegram conversation, Telegram; at the desk, the desk; away, the
|
||||
away channel. Unanswered, it escalates. **Context never lowers the bar** ([07](07-the-work-context-and-the-desk.md)).
|
||||
5. **The desk is a full participant.** Notification actions and the launcher's prompt carry every
|
||||
ordinary ask, with no account anywhere.
|
||||
6. **Asks that authorise are a layer on top,** held by the controller. Three tiers (acknowledge,
|
||||
approve, destroy), and three proofs the operator is answering (a verified Telegram sender, a code,
|
||||
a security key's touch bound to the ask). The controller checks the channel's declared
|
||||
capabilities from its own records and verifies codes and key assertions itself
|
||||
([08](08-asks-that-authorise.md)).
|
||||
7. **No agent authorises.** An agent asks; the operator answers where they are. On an X11 desk, where
|
||||
an agent could click for them, only a code or a key touch counts. In a Telegram conversation, the
|
||||
ask appears in that chat and is completed there, destroy included.
|
||||
8. **Telegram is the first holder and the away channel.** It is free, on both phone platforms, needs
|
||||
no server of the mesh's own, and is the only candidate that carries every tier
|
||||
([04](04-telegram-as-the-first-holder.md), [05](05-the-other-holders-on-the-same-axes.md)).
|
||||
9. **The watcher's watcher stays outside the seats,** with its own bot, on a machine that is not the
|
||||
control node. A free outside dead-man service covers the rest. Pushover is the optional holder for
|
||||
waking. Matrix is the self-hosted carrier once its push is measured.
|
||||
10. **The built Telegram code needs D1–D4 fixed before it is configured**
|
||||
([04](04-telegram-as-the-first-holder.md)).
|
||||
|
||||
## What the operator does
|
||||
|
||||
Minimal, in order. Steps 1–6 are possible today. The desk needs nothing from the operator: no account,
|
||||
no bot.
|
||||
|
||||
1. **Make two bots.** In Telegram, open BotFather and run `/newbot` twice: one for the mesh's
|
||||
conversation, one for the watcher. Keep each token out of agent sessions.
|
||||
2. **Close them to groups:** `/setjoingroups` → Disable, for each bot.
|
||||
3. **Press Start** in each bot's chat. A bot cannot write first.
|
||||
4. **Turn on Telegram's two-step verification,** if it is not on.
|
||||
5. **Find your chat id.** Until the linking verb exists, call `getUpdates` once, reading the token from
|
||||
a file, and take `message.chat.id` from your `/start`. It is the same for both bots.
|
||||
6. **Give the mesh the values,** through the controller, never on disk:
|
||||
- accept the mesh bot's token as the output seat's holder's own secret `telegram-token`, and set
|
||||
its `telegram-chat-id`;
|
||||
- assign the watcher to a machine that is not the control node, accept the watcher bot's token,
|
||||
and set its chat id;
|
||||
- push both machines, and run each module's test verb.
|
||||
7. **Later, once built:**
|
||||
- make a free dead-man check, and give its ping address to the mesh;
|
||||
- enrol an authenticator, at a plain terminal;
|
||||
- optionally, enrol a security key, to approve at the desk with one touch.
|
||||
|
||||
## What would change in the code (proposal, not built)
|
||||
|
||||
- **Now, in the output seat's holder and the watcher:** D1 (cut at 4096 characters), D2 (a reopening
|
||||
is a new message), D3 (a clearing reaches the newest message), D4 (`disable_notification` for
|
||||
warnings and clearings), then D5–D10.
|
||||
- **Catalogue:**
|
||||
- a claim carries `kind` and `capabilities`;
|
||||
- a kinded bench refuses two holders of one kind;
|
||||
- the vocabulary `channel-capabilities/1` and its contract tests;
|
||||
- the shared library publishes on a seat's event subjects.
|
||||
- **Router:**
|
||||
- asks (`ask`, `ask cancel`, `asks`, `answer`, and the events `ask-opened`, `ask-answered`,
|
||||
`ask-closed`);
|
||||
- the work context, from presence events;
|
||||
- the escalation chain.
|
||||
- **Desktop:** `node-notifier.send` gains actions, and the holder emits the chosen one. The
|
||||
screen-lock holder emits lock and idle changes.
|
||||
- **Controller:**
|
||||
- `authorises` on the verb definition;
|
||||
- `authorise request`, `authorise answer`, `authorisations`;
|
||||
- `retire approve` takes `expect`;
|
||||
- the authorising verbs refuse direct calls without a code;
|
||||
- the hand-act gains `via`, `requested-by`, `ask` and `proofs`;
|
||||
- the operator's identities, the TOTP seed and enrolled keys as its own;
|
||||
- a self-check probe: the away channel satisfies every tier.
|
||||
- **Modules:**
|
||||
- a `telegram` module holding `channel` and `intake` (long polling, buttons, replies, `ForceReply`
|
||||
codes, a linking verb with a one-time deep-link code), placed where no agent runs as the operator;
|
||||
- an agent bridge for operator messages;
|
||||
- the dead-man ping.
|
||||
|
||||
## The tables
|
||||
|
||||
### Ask kinds × what a channel needs
|
||||
|
||||
| Ask | deliver | choice | reply | threads | Trust (only if it authorises) |
|
||||
|---|---|---|---|---|---|
|
||||
| a message (no answer) | ✓ | | | | |
|
||||
| acknowledge | ✓ | ✓ | | | tier acknowledge: `exact-render` |
|
||||
| yes-no | ✓ | ✓ or | ✓ | ✓ | tier approve or destroy, if it authorises |
|
||||
| one-of | ✓ | ✓ or | ✓ (a number) | ✓ | as above |
|
||||
| text, number, date | ✓ | | ✓ | ✓ | never authorises |
|
||||
|
||||
### Authorising tiers × proofs
|
||||
|
||||
| Tier | Needs | Telegram | Desk with a key | Desk without a key |
|
||||
|---|---|---|---|---|
|
||||
| acknowledge | `choice`, `exact-render` | tap | click | click |
|
||||
| approve | + one proof | tap (P1) | click + touch (P3) | click + code (P2) |
|
||||
| destroy | + two proofs, one of them P2 or P3 | tap + code (P1 + P2) | click + touch + code (P3 + P2) | carried by Telegram |
|
||||
|
||||
### Surfaces × declared capabilities
|
||||
|
||||
✓ declared, ~ conditional, blank not.
|
||||
|
||||
| Surface | deliver | reaches-away | loud | silent | edit | choice | reply | threads | operator-first | verified-sender | exact-render | code-factor | key-factor | private | reaches-when-mesh-down |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| telegram | ✓ | ✓ | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ (placed apart from agents) | ✓ | ✓ | | | |
|
||||
| desktop (dunst + launcher) | ✓ | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | | ✓ | ✓ | ~ (a key plugged in) | ✓ | |
|
||||
| matrix (own server) | ✓ | ~ (push unmeasured) | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | ~ | |
|
||||
| ntfy | ✓ | ✓ | ✓ | ✓ | | ~ (http action) | | | | | ✓ | | | ~ | ~ (ntfy.sh) |
|
||||
| pushover | ✓ | ✓ | ✓ | ✓ | | ~ (acknowledge) | | | | ✓ | ✓ | | | | ✓ |
|
||||
| mail (outside relay) | ✓ | ✓ | | ✓ | | | ✓ | ✓ | ✓ | | ✓ | ~ | | | ✓ |
|
||||
| an agent's terminal | — | | | | | | ~ (relayed) | | | | | | | | |
|
||||
| the console | — | | | | | | | | | | ✓ (own output) | ✓ | | | |
|
||||
| the watcher's sender | ✓ | ✓ | | | | | | | | | | | | | ✓ |
|
||||
|
||||
### Where things go, by context
|
||||
|
||||
The full table is in [07](07-the-work-context-and-the-desk.md). In one line each:
|
||||
|
||||
- **in a Telegram conversation:** that chat;
|
||||
- **at the desk:** the desk, or the away channel when the desk cannot carry it;
|
||||
- **away:** the away channel;
|
||||
- **night:** urgent only;
|
||||
- **unanswered:** the next in the chain;
|
||||
- **the desk locks:** it moves away.
|
||||
|
||||
## The proposed record
|
||||
|
||||
> **Title.** The mesh holds a conversation with its operator over channels that are seats, chosen by
|
||||
> capability and work context, and an answer that performs an action is authorised by the controller.
|
||||
>
|
||||
> **Context.** The output channel was built in its minimal form (ADR 0227, to-be 45 §5): one holder
|
||||
> carrying the router, a Telegram client and a desktop adapter, and no answering back.
|
||||
> - The operator expects many channels and many inputs.
|
||||
> - The operator wants agents and modules to ask questions, not only for permission.
|
||||
> - The operator wants the work context to choose the channel, and every authorisation completable on
|
||||
> the away channel.
|
||||
> - Today, actions that need a person are verbs any granted principal can call, agents included, and
|
||||
> a hand-act records the calling principal, not the person.
|
||||
>
|
||||
> **Considered options.**
|
||||
> 1. Channels as contributions to the output seat. A channel is running code with a secret and
|
||||
> answers, not content a holder places.
|
||||
> 2. One seat per channel kind. The router learns every seat.
|
||||
> 3. Channel modules found by a manifest field. Callers use seats, never modules (ADR 0126).
|
||||
> 4. One notifier with every channel built in. Shared failure, shared secrets, a release per channel.
|
||||
> 5. A per-service module with its own approval or question path. Locks the conversation to one
|
||||
> service.
|
||||
> 6. **Kinded benches for out and in, a capability vocabulary, a router that holds the conversation
|
||||
> and orders channels by work context, and an authorising layer held by the controller. Chosen.**
|
||||
>
|
||||
> For answers:
|
||||
> - a webhook, or **long polling (chosen)**;
|
||||
> - authorising actions called directly by the channel module, or **requested and answered through
|
||||
> the controller (chosen)**;
|
||||
> - trusting a desktop click, or **requiring a code or a key touch the controller verifies (chosen)**.
|
||||
>
|
||||
> **Decision.**
|
||||
> - **The conversation.**
|
||||
> - Two mesh seats are kinded benches: `channel` (send, edit, standing) and `intake` (one envelope
|
||||
> per input).
|
||||
> - Each holder is its own module, claims one kind, and declares capabilities from
|
||||
> `channel-capabilities/1`, each with a contract test and a drill.
|
||||
> - The output seat's holder routes messages and asks (yes-no, one-of, text, number, date,
|
||||
> acknowledge) by required capability, then by work context, then by severity, escalating when
|
||||
> unanswered. It says when nothing can carry something.
|
||||
> - Asks have timeouts, defaults (never for an authorising ask), cancellation, a per-asker limit,
|
||||
> batching and 30 days of history. Answers return to the asker as events.
|
||||
> - Presence is current state on the bus, never a history, never in a message's words.
|
||||
> - **Asks that authorise.**
|
||||
> - The controller holds them: `authorise request` (anyone; never performs), `authorise answer`
|
||||
> (intake holders only), `authorisations`.
|
||||
> - It checks the caller's declared capabilities from its own records, the sender against its own
|
||||
> list of the operator's identities, codes and key assertions itself, and the exact state's digest,
|
||||
> single use and expiry.
|
||||
> - Tiers acknowledge, approve and destroy require none, one and two proofs. The away channel must
|
||||
> satisfy every tier, checked by the self-check, unless the operator chose otherwise for a tier.
|
||||
> - No agent authorises. The authorising verbs refuse direct calls except as break-glass with a
|
||||
> code.
|
||||
> - The hand-act records `via`, `requested-by`, `ask` and `proofs`.
|
||||
> - **First holders.**
|
||||
> - Telegram is the first holder of both seats and the away channel, placed where no agent runs as
|
||||
> the operator.
|
||||
> - The desktop holds both for the desk.
|
||||
> - The watcher's watcher stays outside the seats with its own bot, and an outside dead-man service
|
||||
> is pinged by the self-check and the watcher.
|
||||
>
|
||||
> **Consequences.**
|
||||
> - The output seat's holder loses its Telegram client to a module of its own and gains asks and the
|
||||
> work context.
|
||||
> - The catalogue gains `kind` and `capabilities` on a claim, and a second kind of bench.
|
||||
> - `node-notifier.send` gains actions.
|
||||
> - The controller's verb definition gains `authorises`, and `retire approve` takes the set it
|
||||
> approves.
|
||||
> - Agents' grants lose authorising verbs.
|
||||
> - To-be 45 §5 is amended: the operator answers.
|
||||
> - Telegram sees the words, held to the content rule, and never a factor's secret.
|
||||
>
|
||||
> **How it is checked.**
|
||||
> - **Catalogue tests:**
|
||||
> - an unknown capability is refused;
|
||||
> - two holders of one kind are refused;
|
||||
> - each declared capability's contract test runs in its holder's build.
|
||||
> - **Router tests:**
|
||||
> - an ask goes only to channels whose capabilities satisfy it;
|
||||
> - context reorders but never adds a channel;
|
||||
> - an authorising ask never defaults;
|
||||
> - a cancelled ask's copies are edited;
|
||||
> - a fourth open ask from one asker is refused.
|
||||
> - **Controller tests,** one per refusal of `authorise answer`:
|
||||
> - from a non-intake principal;
|
||||
> - from a kind that does not meet the tier;
|
||||
> - from an identity not on the list;
|
||||
> - with too few proofs;
|
||||
> - with a used code;
|
||||
> - with a key assertion over another ask's challenge;
|
||||
> - with a stale digest;
|
||||
> - a second answer.
|
||||
>
|
||||
> Also: a direct `retire approve` without a code.
|
||||
> - **Self-check probe:** the away channel meets every tier.
|
||||
> - **Live drills:**
|
||||
> - an agent's question answered at the desk;
|
||||
> - the same with the desk locked, answered on the phone;
|
||||
> - an approve and a destroy on a test condition, answered on the phone, with the hand-acts read.
|
||||
Reference in New Issue
Block a user