Research 028: frame it as a conversation with the operator; add asks, work context and the desk

The operator widened the direction: asks need not be about permission, the work context picks
the channel, and desktop buttons should serve at the desk. Make the conversation the core model
and keep authorisation as a layer on top, with proofs a desk click alone cannot give.
This commit is contained in:
jochen
2026-10-06 16:24:23 +02:00
parent 62db332b3e
commit b140ac9af7
11 changed files with 977 additions and 717 deletions
@@ -41,23 +41,30 @@ The effort looks at:
silenced by the operator;
- **the watcher's watcher:** who tells the operator when the parts that would tell them are the
ones that failed;
- **the way back in** (widened 2026-10-06): the operator's answers, and decisions, through a channel;
and inputs from outside (a message from the operator, a mail arriving) as triggers of the same shape;
- **the capabilities** a channel declares, and the decisions that may only travel on channels whose
capabilities satisfy them.
- **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 approving and rejecting through Telegram, for a generic shape in
which Telegram simply holds a seat, for input triggers alongside output channels, and for
capabilities that decide which actions may travel on which channel. That could have opened a new
effort. It did not, because:
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;
- a decision is an answer to a message this seat sends, and splitting the reply from the message
would leave two efforts each owning half of one conversation.
- 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.
@@ -102,9 +109,14 @@ for the mesh, sources that call it, and channels that deliver.
5. [The other holders, on the same axes](05-the-other-holders-on-the-same-axes.md): ntfy, Matrix,
Pushover, Gotify, mail, Signal, SMS and a dead-man service, as away channel and as the watcher's
path.
6. [Channels and triggers as seats](06-channels-and-triggers-as-seats.md): the kinded benches
`channel` and `intake`, the capability vocabulary, the router, agents as surfaces, the migration.
7. [Deciding from a channel](07-deciding-from-a-channel.md): the decisions that exist, three tiers,
decisions held by the controller, Telegram's buttons and codes, compromise.
8. [A proposed decision](08-a-proposed-decision.md): the recommendation, the operator's steps, the
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,7 +1,7 @@
# 03 — Open questions
Each question names the options seen so far. None is decided here. Q1 and Q7 are taken further in
[06](06-channels-and-triggers-as-seats.md) and [07](07-deciding-from-a-channel.md).
[06](06-a-conversation-with-the-operator.md) and [08](08-asks-that-authorise.md).
## Q1. The seat
@@ -5,7 +5,7 @@ the output seat's holder carries a Telegram client, and so does the watcher's wa
Neither is configured, because no bot exists yet. This document is what the operator needs to make one,
what the mesh's use of the bot API must respect, and what the built code gets wrong against it.
[06](06-channels-and-triggers-as-seats.md) makes Telegram one holder of a generic channel seat rather
[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.
@@ -61,7 +61,7 @@ In a private chat the chat id equals the person's user id. There are two ways to
The holder reads it by `getUpdates`, and binds **that** chat and **that** user id only if the code
matches and is fresh. This proves the chat belongs to whoever held the code, and it never shows the
token to anyone. It needs the holder to read updates, which approvals need anyway
([07](07-deciding-from-a-channel.md)).
([08](08-asks-that-authorise.md)).
The second is the one to build. The first is the stop-gap until it exists.
@@ -88,7 +88,7 @@ The holder and the watcher each have their own secret. They can hold the same to
before the request may be repeated. Repeating early prolongs the wait.
- **Length.** A text message is at most 4096 characters after entity parsing. Longer is refused with
HTTP 400, not cut.
- **Callback data** on a button is 1–64 bytes ([07](07-deciding-from-a-channel.md)).
- **Callback data** on a button is 1–64 bytes ([08](08-asks-that-authorise.md)).
### Editing
@@ -129,7 +129,7 @@ Answers reach a bot two ways:
carry a secret header (`X-Telegram-Bot-Api-Secret-Token`) that proves the call came from the webhook
that was set.
Only one of the two at a time. [07](07-deciding-from-a-channel.md) chooses between them.
Only one of the two at a time. [08](08-asks-that-authorise.md) chooses between them.
## What Telegram sees
@@ -1,15 +1,15 @@
# 05 — The other holders, on the same axes
Every candidate is judged as a holder of the channel and intake seats in
[06](06-channels-and-triggers-as-seats.md): which capabilities it can honestly declare
[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 decide something through
it?** ([07](07-deciding-from-a-channel.md)).
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 decisions, wherever the operator is.
- **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
@@ -92,7 +92,7 @@ beyond opening the app. **Not pursued:** it covers less than ntfy and nothing nt
- **Answers:** a reply, slowly. **Who answered is weak:** a From line can be forged, and checking
DKIM only proves the operator's provider sent it.
- **Mail as an intake** (a new mail arriving) is a trigger in its own right
([06](06-channels-and-triggers-as-seats.md)), whatever its weakness as a channel for decisions.
([06](06-a-conversation-with-the-operator.md)), whatever its weakness as a channel for asks that authorise.
### Signal, through `signal-cli`
@@ -128,10 +128,10 @@ also covers "the whole house is offline".
## The table
Capabilities are those of [06](06-channels-and-triggers-as-seats.md). ✓ declared honestly,
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 | second-factor | private | off the control node | cost |
| 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 |
@@ -145,17 +145,17 @@ Capabilities are those of [06](06-channels-and-triggers-as-seats.md). ✓ declar
## Reading it
**For the away channel and for decisions,** Telegram is the only candidate that is all of these at
**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 decision ([07](07-deciding-from-a-channel.md)), including a second factor. Its price is that
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 decisions. It waits on a measurement of Conduit's push.
- **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 a decision beyond
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:
@@ -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.
@@ -1,271 +0,0 @@
# 06 — Channels and triggers as seats
The operator's direction, 2026-10-06, in substance:
- **Telegram is one form of output channel among many to come.** The setup must be generic, and
Telegram simply fulfils a seat.
- **The same holds for input:** a new mail, a new message from the operator, a button pressed.
- **Each channel has capabilities,** human approval buttons for example, and these force some actions
to be allowed only through some channels.
This document is the generic shape. [04](04-telegram-as-the-first-holder.md) and
[05](05-the-other-holders-on-the-same-axes.md) are its first holders.
[07](07-deciding-from-a-channel.md) is the first thing it carries that is not a notification: a
decision.
## What exists, and how it is shaped
Read from the catalogue's main branch on 2026-10-06.
- **The output seat's holder is one module doing three jobs.** It claims `operator-channel` (mesh
scope, serving `open`, `history` and `notify`). It consumes the controller's three condition events.
It holds the Telegram bot token as its own secret. It reaches the desktop by invoking the
`node-notifier` seat's `send`.
- So the router, the Telegram client and the desktop adapter are one process with one manifest.
- `notify` is a served verb, not the work queue to-be 32 §3 sketched for a `telegram-sender` seat.
- To-be 45 §5 says each channel is "a module contributing itself to the seat". The code does not do
that, and on a closer look that shape does not fit (below).
- **The desktop notifier** is a node seat, `node-notifier`, held by the dunst module on each graphical
machine (`send`, `history`).
- **The watcher's watcher** is a second module with its own Telegram client and token. It sends
directly, by design, and is not assigned anywhere yet.
- **Nothing reads anything back.** No input from the operator reaches the mesh except through an agent
session or a shell.
## Where a channel sits: [03](03-open-questions.md) Q1, asked again
Q1 settled that **one seat speaks for the mesh** and that sources never learn channels. It left open
how a channel attaches to that seat, and assumed contribution. With many channels to come, and with
channels that answer, that is the question to settle.
The mesh's seat model offers these precedents:
- A **seat** has one holder at its scope (ADR 0121, ADR 0126).
- A **node seat** has one holder per machine, and a verb's subject carries the machine
(`…tool.<verb>.<node>`, design 33 §4).
- A **bench** is a seat with several holders (glossary). The only one is `mesh-dns-resolver`: the
same module, one per machine. "Making another one is a decision, recorded" (ADR 0223).
- A **work queue** is shared by a seat's holders, and one of them takes each ask (ADR 0190).
- A **contribution** is content another module hands to a seat's holder, which places it
(ADR 0210, ADR 0212).
The options:
- **a. Each channel contributes itself to the output seat** (Q1 a, to-be 45 §5).
- A contribution is content: a rule file, a hotkey, a fragment the holder places (ADR 0212).
- A channel is running code. It holds its own secret, keeps a connection, polls for answers, and
fails on its own. None of that is content.
- To make it fit, the router would have to carry every channel's client, which is today's
monolith again. **Rejected.**
- **b. One seat per channel kind** (`telegram-channel`, `ntfy-channel`, …).
- The router must learn each seat, and every new channel is a change to the router.
- That is Q1 c's objection moved one level down. **Rejected.**
- **c. Channel modules found by a manifest field and called by module address.**
- ADR 0126: callers use the seat, never the module. **Rejected.**
- **d. One monolithic notifier,** with every channel built into the router (today's shape, grown).
- Every channel's secrets, dependencies and failures share one process.
- A new channel is a release of the router.
- The watcher's independence becomes the only exception, instead of the rule's natural case.
- **Rejected.**
- **e. A kinded bench.** One mesh seat, `channel`, whose holders are **different modules**, each
claiming it under a **kind** (`telegram`, `desktop`, `ntfy`, `matrix`, `mail`, …).
- One holder per kind. Two modules claiming one kind is refused at registration, as two modules
claiming one machine of a bench are today.
- A verb's subject carries the kind, as a node seat's carries the machine:
`mesh.seat.channel.tool.send.<kind>`.
- The router addresses "the channel seat, kind telegram", never a module.
- A new channel is a new module claiming a new kind, with no change to the router.
- **Chosen.**
Option **e** needs two things the mesh does not have yet:
- **A second bench, of a new sort.** Its holders are different modules, keyed by kind instead of
machine. ADR 0223 requires that to be decided.
- **A claim that carries a kind and capabilities.**
Both are small beside what they buy. The same shape serves input.
## The channel seat (output)
**`channel`**, mesh scope, a kinded bench.
### What a holder serves
- **`send`:** a message (title, body, severity, silent or not, optional choices — see
[07](07-deciding-from-a-channel.md), optional reply-to handle). Answers the channel's id for the
message, or a refusal in words.
- **`edit`:** replace a sent message by its id. Only for a holder that declares `edit`.
- **`reply`:** say something into a conversation, by the handle an intake envelope carried. Only for
a holder that declares `reply`.
- **`standing`:**
- ready, or not configured (and what is missing, named, never a value), or failing (since when,
why);
- the last delivery;
- the declared capabilities.
### What a holder emits
- **`delivered`** and **`failed`**, each carrying the message's key and the channel's id. A refusal
is marked **permanent** or **transient**, so the router never retries a permanent refusal for ever
(defect D6 in [04](04-telegram-as-the-first-holder.md)).
### What a holder must honour
- **Its declared maximum length:** cut and say so, never refuse and wedge (D1).
- **`silent`** where it declares it (D4).
- **An identical edit is a success** (D6).
- **The token and every other own secret stay out of every error and event,** as the Telegram
client already does.
### The content rule moves to the router
A message bound for a holder that does not declare `private` passes the content rule first. The rule
is the router's, so no holder can forget it.
## The capability vocabulary
**Fixed and versioned:** `channel-capabilities/1`. A holder declares capabilities in its claim. The
catalogue refuses a word outside the vocabulary.
**A declaration is a promise with a test.** For each capability the catalogue holds a contract test
the holder's module must pass at build time, and a live drill the holder's `standing` can run on
request. An undeclared capability is never assumed; a declared one that fails its drill is reported as
`channel-capability-broken` and withdrawn from routing until it passes.
### Delivering
| Capability | Promise | Contract test / drill |
|---|---|---|
| `deliver` | A message reaches the operator's device, or `failed` says why. | Send to a test double. Live: the holder's test message. |
| `reaches-away` | It reaches a phone away from the operator's machines. | Declared by kind. Drill: the operator acknowledges a test. |
| `loud` | It can break through the phone's quiet hours. | The holder maps urgent to the service's override. Drill acknowledged. |
| `silent` | It can deliver without sound. | The request carries the service's silent flag. |
| `edit` | A sent message can be replaced in place. | Edit, then read back, against a double. |
| `max-length:N` | Messages up to N characters arrive whole. Longer ones are cut, and the cut is said. | N+1 characters give a cut message, not a failure. |
| `reaches-when-mesh-down` | Delivering needs neither the bus nor the control node. | Declared only by a holder not on the control node, with no bus call on the send path. Checked by the manifest's placement. |
| `private` | The words stay on the operator's machines, or are end-to-end encrypted. | Declared by kind. Reviewed, not tested. |
### Answering ([07](07-deciding-from-a-channel.md))
| Capability | Promise | Contract test / drill |
|---|---|---|
| `choice` | The operator can pick one of the offered options in one act, and the pick comes back as an intake event. | A simulated tap gives a `choice` envelope with the option's token. |
| `reply` | The operator can answer in free text, on the same conversation. | A simulated reply gives a `reply` envelope with the handle. |
| `verified-sender` | The holder proves the act came from the operator's own account on that service, by the service's authentication, with nothing in between that relays words. | A tap from a non-allowlisted identity is dropped and reported. A relayed text never carries the flag. |
| `exact-render` | A decision is shown as the controller rendered it, by the holder itself. No agent or other program composes the words the operator decides on. | The rendered text equals the controller's, byte for byte, against a double. |
| `second-factor` | The holder can carry a short code the operator types, to the controller, which verifies it. The holder never judges the code. | A code reply is handed to the controller by request and reply, never as an event, and is deleted from the conversation where the service allows. |
## The intake seat (input)
**`intake`**, mesh scope, a kinded bench, held by the same sort of modules. Telegram claims both
`channel` and `intake` under the kind `telegram`, because one program must read the bot's updates
([04](04-telegram-as-the-first-holder.md)).
A holder turns something from outside into **one envelope**, and emits it as an event on the seat:
| Field | What it is |
|---|---|
| `id` | Unique, for deduplication. |
| `kind` | The holder's kind: `telegram`, `mail`, `matrix`, `webhook`, … |
| `what` | `message` (the operator wrote), `choice` (a button or reaction), `reply` (an answer to something the mesh sent), `mail`, `call` (a webhook), `seen` (the operator was active here). |
| `sender` | The identity on that service (a user id, a Matrix id, a mail address), and `verified`: whether the service authenticated it **and** the holder declares `verified-sender`. |
| `operator` | Whether that identity is on the **controller's** list of the operator's identities. The holder fills it from the controller's list, never from a list of its own. |
| `conversation` | An opaque handle. Answering on it (`channel.reply <kind> <handle>`) goes back to the same chat, room or mail thread. |
| `in-reply-to` | The mesh message or decision it answers, if any. |
| `payload` | The text, the choice's token, the mail's subject and body. It passes the same content discipline as everything on the bus: **no secrets in events.** A second-factor code is never in an envelope. |
| `at` | When. |
**Consumers subscribe by `what`:**
- the router: `seen`, for where the operator is;
- the controller: `choice` and `reply` to decisions ([07](07-deciding-from-a-channel.md));
- an agent bridge: `message`, from a verified operator;
- a future mail rule: `mail`.
**Who "the operator" is, per kind, is the controller's record,** not each holder's setting. A channel
module cannot widen it. Adding an identity is itself a decision ([07](07-deciding-from-a-channel.md)).
**One gap to close:** design 32 §1 notes the shared library cannot yet publish on a seat's event
subjects (`mesh.seat.<seat>.event.<x>`). Intake needs it.
## The router
The output seat's holder becomes **only** the router. It keeps:
- `operator-channel`: its messages, their life (deduplication, reminders, clearing), the rate cap,
`open` and `history`;
- and gains the decisions to deliver ([07](07-deciding-from-a-channel.md)).
### How it chooses, per message
1. **What the message needs.** A notification needs `deliver`. A decision needs what its tier
requires ([07](07-deciding-from-a-channel.md)). Urgent prefers `reaches-away` and `loud`.
2. **Where the operator is.** The most recent verified `seen` or `message` on an intake kind within a
bound (15 minutes, a setting) wins. Next comes the desktop when its session answers. Last is the
operator's chosen **away channel** (a setting naming a kind).
3. **Only channels whose declared capabilities satisfy the message,** filtered by `standing`.
4. **Nothing able to carry it:** the router says so on whatever can deliver, and keeps it open as a
condition of its own ("a decision is waiting and no channel can carry it: telegram is failing
since …"). It never degrades a decision to a channel that cannot carry it.
## Agents as surfaces
An agent session is a place the operator talks to the mesh. It is a surface with capabilities like
any other.
- **An agent in a terminal** relays the operator's words. It declares nothing for deciding:
- not `verified-sender`, because what reaches the mesh is the agent's call, not the operator's act;
- not `exact-render`, because the agent composes what the operator reads.
An agent must never approve on the operator's behalf because "the user said yes". It **requests**
a decision ([07](07-deciding-from-a-channel.md)); the request goes to a capable channel; the
operator decides there. The agent sees the outcome as an event. Having to open Telegram while
working in a terminal is acceptable to the operator.
- **An agent the operator talks to through Telegram** is reached by the intake: verified `message`
envelopes from the operator go to an agent bridge, and its answers go back by `channel.reply` on the
same conversation. When it needs a decision it requests one with the conversation's handle. The
router puts the decision **into that conversation**, rendered by the Telegram holder, with its
buttons. The operator's tap is the holder's verified act, not the agent's relay. So the decision is
made in place. The operator cannot switch to a desk while working this way, so nothing may require
one ([07](07-deciding-from-a-channel.md)).
## The watcher, in this shape
The watcher's watcher stays **outside** the seats, deliberately:
- its whole point is to speak when the bus and the control node are what failed;
- the router and the seats live on the bus.
It shares the vocabulary only: its sender is the minimal `deliver` + `reaches-when-mesh-down` client
it already is, with **its own bot**. It never reads updates and never carries a decision.
Its sibling outside the mesh is the dead-man service ([05](05-the-other-holders-on-the-same-axes.md)),
which the self-check and the watcher both ping.
## From today to this shape
The steps are ordered so that each one is useful alone.
1. **Fix the first holder in place:** D1–D4 of [04](04-telegram-as-the-first-holder.md), in the
output seat's holder and the watcher. No change of shape.
2. **The operator configures Telegram:** two bots ([08](08-a-proposed-decision.md)). The mesh starts
telling.
3. **The vocabulary and the kinded bench.** The catalogue learns `kind` and `capabilities` on a claim,
the vocabulary file and its contract tests. Registration refuses two holders of one kind.
4. **Split Telegram out** into its own module, holding `channel` and `intake` under `telegram`.
- The router keeps the desktop adapter as the holder of `channel/desktop`, until a reason appears
to move it next to dunst.
- The bot token moves with the module, as an own secret `issued-by: outside`, accepted again.
5. **Decisions in the controller,** and buttons on Telegram ([07](07-deciding-from-a-channel.md)).
6. **Intake for the operator's messages,** and an agent bridge consuming them.
7. **Further holders as wanted:** Pushover for waking, Matrix once its push is measured, mail out
through an outside relay for the digest, mail in as an intake.
The watcher changes only at step 1.
## What this revisits in [03](03-open-questions.md)
- **Q1:** the channels attach as holders of a kinded bench (e), not as contributions.
- **Q3:** "a channel that can edit" becomes the declared `edit`. Whether a clearing is said by edit
depends on `silent` as well (D2, D3).
- **Q4:** presence gains a source: verified intake activity.
- **Q7:** answering back is designed in [07](07-deciding-from-a-channel.md).
- **Q8:** the content rule stays and moves to the router. A `private` holder may be exempted, a
choice left to graduation.
@@ -1,226 +0,0 @@
# 07 — Deciding from a channel
The operator, 2026-10-06:
- "Make sure I can approve and reject stuff via the Telegram channel."
- In a terminal session with an agent, having to open Telegram to react is acceptable.
- But when talking to an agent **through** Telegram, the operator cannot switch to a desktop session.
So every decision must be completable on the operator's away channel, including the strongest, and
no agent may ever decide in the operator's place.
## Where this stands against what was decided
- To-be 45 §5 says: "No answering back in this form; acknowledging is `conditions silence`, through the
mesh."
- ADR 0227 adopted that minimal form, and kept the rest open on purpose: "routing by presence, quiet
hours, **answering back** and the external dead-man service stay open in 028, whose graduation amends
to-be 45."
- So answering back is not a reversal of ADR 0227. It is this effort's open question Q7, now
answered. Its graduation amends to-be 45 §5.
## What there is to decide
Read from the controller's main branch on 2026-10-06. The condition store already marks the
conditions only a person resolves: `resolver: operator`. That is set from the start for retirement,
clean-up and binding conditions, and set when a healer's budget is spent.
| Decision | The verb today | Asked for by | Reversible | Tier |
|---|---|---|---|---|
| Approve the retirement set waiting | `retire approve <node> <provider> --why` | `retire-waiting` (urgent) | yes: asking for a consumer again re-enables it | approve |
| Reject it | `retire reject … --why` | `retire-waiting` | yes: a rejected set can be approved later | approve |
| Approve a set rejected before | `retire approve …` | `retire-rejected` (warning) | yes | approve |
| Confirm that a binding moves, once its data is moved | `pin <node> <provision> <from> <module>` | `binding-kept` (urgent) | the pin, yes (`unpin`). The data, not by the mesh. | approve |
| End a stuck plan | `plans stop` / `plans close <id> --why` | `stalled`, `sent-not-reported` once escalated | no, but it destroys nothing | approve |
| Send a machine its declaration by hand | `push <node> --why` | `sent-not-reported` once escalated | n/a | approve |
| Reset a bus consumer's position | `broker consumer-reset --why` | `consumer-behind` once escalated | no: messages are skipped or redelivered | approve (graduation to confirm) |
| Try a healer's repair once more, by hand | the healer's ordinary path | any healer escalation (H1–H5), `healers-braked` | as the repair is | approve |
| Silence a condition for a while | `conditions silence <key> --for --why` | any | yes: it ends by itself, at most 7 days | acknowledge |
| Delete one retired consumer's data | `cleanup delete <node> <provider> <consumer> --why` | `cleanup-waiting` (warning, after 30 days) | **no** | destroy |
| Delete everything retired longer than N days | `cleanup delete --older-than N --confirm --why` | `cleanup-waiting` | **no** | destroy |
| Add an identity to the operator's list, change the away channel, enrol a second factor | (new) | none | yes, but it changes who may decide | destroy |
Not offered on a channel in the first form: build queue verbs (`cancel`, `kill`, `clear`, `pause`),
`replay --register`. No condition asks for them, and they remain at the console.
## Three tiers, each a set of required capabilities
The capabilities are those of [06](06-channels-and-triggers-as-seats.md).
| Tier | Required of the channel | And of the decision |
|---|---|---|
| **acknowledge** | `choice`, `verified-sender`, `exact-render` | single use, expires with the condition |
| **approve** | `choice`, `verified-sender`, `exact-render` | single use, bound to the exact state shown, expires when that state changes or after 24 h |
| **destroy** | `choice`, `reply`, `verified-sender`, `exact-render`, `second-factor` | as approve, plus a code from the operator's authenticator verified **by the controller**, valid 10 minutes after it is shown, at most one destroy decision answered per 10 minutes |
### The rule
1. **Every decision declares its tier,** in the controller's verb table (below).
2. **A decision is offered only on a channel whose declared capabilities satisfy its tier.** Arriving
from any other channel, it is refused, and the refusal is said on the channel it came from.
3. **The operator's away channel must satisfy every tier.** The self-check verifies it. A design or a
setting that would make a tier possible only at a desk is refused, unless the operator has said so
explicitly for that tier, in a setting the self-check reads.
4. **No agent decides.** An agent requests. It never holds a verb that performs a decision, and a
decision's record names the agent that asked.
## The controller carries decisions
Today a grant covers a whole verb (`seat:mesh-controller.retire` allows approve and listing alike).
Hand-acts record `by` from the caller's bus principal, which for a channel module would be the
module, not the person. Both call for a decision to be a thing the controller holds.
### The verb table gains a field
The controller's verb definition (its table of the mesh's own verbs) has a name, a description and
input and output schemas. It gains **`decision`**: the tier, and which arguments make up the exact
state a person must see (for `retire approve`, the set of consumers).
### Three new verbs
- **`decide request`.** Any principal may call it: an agent, a module, the router on a condition's
behalf. It carries the action (verb and exact arguments), why, and optionally a conversation handle.
- The controller renders the decision's text itself: what is asked, the exact state (for example the
consumers in the set), who asks, what each option does.
- It stores the decision in its own state with an opaque id (10 random base32 characters), the
expiry, and a digest of the state shown.
- It emits `decision-requested`. **It never performs anything.**
- **`decide answer`.** Only intake holders are granted it. It carries the id, the option, the sender's
identity as the service authenticated it, and the code for a destroy decision. The controller
checks, in order, and refuses with the reason at the first failure:
1. the decision exists, is open, and has not expired;
2. the **calling principal** is the holder of an intake kind, read from the controller's own seat
records, never from the request;
3. that kind's **declared** capabilities, from the controller's own records, satisfy the tier;
4. the sender's identity is on the controller's list of the operator's identities for that kind;
5. for destroy: the code is valid for the current or previous 30-second step, and not used before;
6. the state **now** has the digest it had when shown (for `retire approve`, the waiting set is
exactly the one rendered). Otherwise the decision is void and a new one is requested.
Then it performs the action as itself, records the hand-act, closes the decision with a
compare-and-set on its stored revision (so a second answer from another channel loses), and emits
`decision-answered`.
- **`decisions`:** open and recent decisions, for any reader.
### The verbs that decide become reachable only this way
The decision-bearing verbs (`retire approve|reject`, `cleanup delete`, `pin` while a `binding-kept`
names it) refuse a caller unless the call comes through `decide answer`, or carries a valid
second-factor code as **break-glass** at the console. Break-glass is recorded as such, and announced on
every channel. So the console, which agents can drive, never decides without a code only the operator
holds.
- `retire approve` must accept the set it is approving. Today it re-reads the set at the moment it
runs, so "approve what you were shown" does not hold end to end (ADR 0230). It needs an `expect`
argument the provider compares.
- `conditions silence` stays callable as today (it hides, it destroys nothing). A silence set by an
agent is said on the away channel with its why.
### Who answered, in the record
The hand-act gains:
- **`via`:** the kind and the holder module;
- **`requested-by`:** the agent principal or the condition key;
- **`decision`:** the id;
- **`factor`:** whether a code was verified.
`by` becomes "the operator, as <kind> identity <id>". The decision's messages on every channel are
edited to the outcome: "approved by the operator on telegram at 14:02 UTC". Its buttons are removed.
## Telegram, as the first holder that decides
- **Buttons.** Each decision message carries an inline keyboard. A button's `callback_data` is at
most 64 bytes, so it carries only `d1:<id>:<option>` (about 16 bytes). Everything else is in the
controller.
- **A tap** arrives as a `callback_query` with the tapping user's id and the message's chat id. The
holder:
1. drops it, and reports, unless the user id is on the operator's list for `telegram` and the chat is
the bound chat;
2. calls `answerCallbackQuery` at once, because the phone shows a spinner until it does;
3. calls `decide answer`;
4. edits the message to the outcome, or to the refusal ("expired: a new one was sent", "the set
changed", "already decided on matrix at …").
- **A destroy decision** answers the tap with a `ForceReply` prompt: "Reply with your 6-digit code to
delete <item>". The operator's reply (same user, same chat) is read, deleted from the chat (bots may
delete incoming messages in private chats), and handed to the controller by request and reply. It
is never put in an event.
- **Long polling, not a webhook.**
- `getUpdates` runs over outbound HTTPS from wherever the holder runs, and needs no route into the
mesh.
- A stolen token can steal updates, but cannot inject one into the holder's stream. A second reader
shows up as HTTP 409, which the holder reports as a condition.
- A webhook needs a public route to the holder's machine, and a stolen token can redirect it.
- The holder keeps its update offset in its own state, so a restart does not hand a tap over twice.
Single-use ids would refuse it anyway.
- Telegram keeps an unread update for 24 hours. A decision unanswered longer expires before that
matters.
- **Free text** from the operator is an intake `message`, for an agent bridge (below). Free text is
never read as a decision: only a tap, or a code replying to a destroy prompt, decides.
## Agents and decisions
- **In a terminal:**
1. The agent calls `decide request`.
2. The router delivers the decision to the operator's away channel, or to wherever verified
activity was most recent.
3. The operator taps there.
4. The agent sees `decision-answered`.
The agent cannot tap, and nothing it says counts.
- **Through Telegram:** the operator's messages reach the agent as verified intake envelopes. The
agent answers by `channel.reply` on the conversation handle. When it calls `decide request` with
that handle, the router delivers the decision **into the same chat**, as a reply in the thread. It is
rendered by the holder from the controller's text, with its buttons. The tap is the operator's own
verified act, so the decision is made in place, destroy tier included, with nothing that needs a
desk.
## Desktop and console, noted
- **The desktop notifier** can show action buttons (dunst returns the chosen action). Any program
running as the operator's account can also invoke a notification's action, and agents run as that
account. So the desktop declares no `verified-sender`. It shows decisions as information, with
"decide on telegram". Nothing is decided there.
- **The console** (a person's account, a shell or an agent's tools) declares `exact-render` only for
the controller's own output and never `verified-sender`, for the same reason. It may decide only as
break-glass, with a code.
## If the phone, the account or a part is compromised
| What is lost | What the attacker can do | What limits it |
|---|---|---|
| The bot token | Read what the bot is sent from then on. Steal taps by polling (seen as 409). Send the operator fake messages. | It cannot answer a decision: only the holder's bus account can call `decide answer`. Revoke with BotFather's `/token`. |
| The operator's Telegram account, on a new device | Tap approve or acknowledge. | Telegram's two-step verification password. Every decision is announced on the other channels and in the digest. Approve-tier acts are reversible. Destroy needs the code. |
| The phone, unlocked | Everything, including destroy, if the authenticator is on the same phone and open. | An authenticator locked behind the phone's biometrics. One destroy per 10 minutes, each announced. Backups of the data a delete removes (research 030). A setting that turns destroy off for the away channel, at the operator's choice. |
| The channel module, or its bus account | Forge a verified approve or acknowledge. | It cannot forge a code: the controller verifies codes itself. Its grant is `decide answer` alone. |
| An agent (prompt injection) | Request decisions, with persuasive words in its why. | It cannot answer. The decision's text is rendered by the controller, naming the agent that asked. |
| Telegram itself | Read the words. In principle, forge a tap. | The content rule. Destroy needs the code, which Telegram never sees. |
The second factor is a TOTP seed held by the controller as its own secret, made by the mesh. It is
enrolled by showing its URI once, only to a terminal (refused when the output is not one), never
through a channel or an event. Re-enrolment is a destroy decision.
## The alternatives, for deciding
- **Matrix:** reactions as choices, replies, and a sender authenticated by the mesh's own homeserver.
It can declare every capability Telegram does, and `private` with an encrypting bot. It is the
self-hosted holder for decisions once its push is measured.
- **ntfy:** an `http` action button makes the phone call a URL, which needs a route into the mesh and a
credential inside the notification. Nothing tells the server who tapped. No reply, so no code. It
can declare no tier.
- **Pushover:** acknowledgement, read back by polling a receipt outbound, and nothing else. It can
carry `acknowledge` at most. Its strength is waking the operator, not asking them.
- **Mail:** a reply can carry a code, but the sender is forgeable. No tier, except as break-glass.
- **Signal:** like Telegram, end-to-end encrypted, at its upkeep cost ([05](05-the-other-holders-on-the-same-axes.md)).
## Sources
As in [04](04-telegram-as-the-first-holder.md), and:
- Bot API `callback_data` (1–64 bytes), `answerCallbackQuery`, `ForceReply`, `deleteMessage` in private
chats: https://core.telegram.org/bots/api
- `answerCallbackQuery` is required even with no text, or the client keeps its progress indicator:
https://gramio.dev/telegram/methods/answercallbackquery
- `setWebhook` ports and the secret-token header: https://core.telegram.org/bots/api#setwebhook
- ntfy `http` actions: https://docs.ntfy.sh/publish/
- Pushover receipts: https://pushover.net/api
- Matrix reactions and their variation selectors in practice:
https://github.com/MarioCakeDev/zooid/pull/14
@@ -0,0 +1,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
@@ -1,189 +0,0 @@
# 08 — A proposed decision, and what the operator does now
This is the effort's reading as of 2026-10-06, written so that playbook 02 can turn it into a record
and amend to-be 45 §5. It is a proposal: nothing here is decided until it graduates.
## The recommendation, short
1. **Channels and intake are seats.** One kinded bench each, `channel` and `intake`. Each holder
is a module of its own, claiming a kind and declaring capabilities from a fixed, versioned
vocabulary, each capability with a contract test and a drill.
2. **The output seat's holder becomes only the router.** It picks channels by what a message needs
(capabilities), where the operator is (verified activity), and severity. It never degrades a
decision to a channel that cannot carry it.
3. **Telegram is the first holder of both seats,** and the operator's away channel. It is free, on both
phone platforms, needs no server of the mesh's own, and is the only candidate able to carry every
decision tier, including a second factor typed as a reply.
4. **Decisions are the controller's.** An agent or a condition **requests** one. The controller
renders it and binds it to the exact state shown. A verified tap on a capable channel **answers**
it. The controller checks the channel's declared capabilities from its own records, and verifies a
destroy decision's code itself.
5. **Three tiers:** acknowledge, approve, destroy. Destroy also needs a TOTP code, so a stolen bot
token, a hijacked Telegram account or a compromised channel module cannot delete anything.
6. **Every tier is completable on the away channel.** The self-check verifies it. Nothing needs a desk
unless the operator chose that for a tier.
7. **No agent decides.** In a terminal, it requests and the operator taps on Telegram. Through Telegram,
the decision appears in the same chat and the operator taps there.
8. **The watcher's watcher stays outside the seats,** with its own bot on a machine that is not the
control node. An outside dead-man service (free) covers the case where the watcher's own
connection is gone too.
9. **Pushover is the optional second away holder** for waking the operator, which Telegram cannot do
through do-not-disturb. **Matrix** is the self-hosted decision holder, once its push is measured.
10. **The built Telegram code needs D1–D4 fixed before it is configured** ([04](04-telegram-as-the-first-holder.md)).
## What the operator does
Minimal, in order. Steps 1–6 are possible today. Step 7 waits for the dead-man ping to be built.
1. **Make two bots.** In Telegram, open BotFather.
- `/newbot` once for the mesh's messages, once for the watcher, each with a username ending in `bot`.
- Keep each token where only you can read it. Do not paste it into an agent session.
2. **Close them to groups:** `/setjoingroups` → Disable, for each bot.
3. **Press Start** in each bot's chat. A bot cannot write to you first.
4. **Turn on Telegram's two-step verification** (Settings → Privacy and Security), if it is not on.
5. **Find your chat id.** Until the linking verb exists, call `getUpdates` once for one bot, reading
the token from a file rather than typing it, and take `message.chat.id` from your `/start`. It is
the same for both bots.
6. **Give the mesh the values,** through the controller, never on disk:
- accept the mesh bot's token as the output seat's holder's own secret `telegram-token`, and set
its `telegram-chat-id`;
- assign the watcher to a machine that is not the control node, accept the watcher bot's token as
its own secret, and set its chat id;
- push both machines, then run each module's test verb and see the two messages arrive.
7. **Later, once built:** make a free Healthchecks.io check, with its own alert to Telegram and mail,
and give its ping address to the mesh as a secret. Then enrol an authenticator for destroy
decisions, at a plain terminal.
## What would change in the code (proposal, not built)
### Output seat's holder and watcher, now
- **D1:** cut at 4096 characters, and say so.
- **D2:** a reopening is a new message, never an edit.
- **D3:** a clearing edits every message of the condition, or replies silently to the first.
- **D4:** set `disable_notification` for warnings and clearings.
- **D5–D10** of [04](04-telegram-as-the-first-holder.md).
### Catalogue and controller, next
- A claim carries `kind` and `capabilities`. A kinded bench refuses two holders of one kind. The
vocabulary file `channel-capabilities/1` and its contract tests.
- The shared library publishes on a seat's event subjects (`mesh.seat.intake.event.<what>`).
- The controller's verb definition gains `decision` (tier, the arguments that are the exact state).
- New verbs `decide request`, `decide answer`, `decisions`. Events `decision-requested` and
`decision-answered`.
- `retire approve` takes the set it approves (`expect`).
- Decision-bearing verbs refuse direct calls without a code (break-glass).
- Hand-acts gain `via`, `requested-by`, `decision` and `factor`.
- The list of the operator's identities per kind, and the TOTP seed, as the controller's.
- A self-check probe: the away channel satisfies every tier.
### Modules, after
- A `telegram` module holding `channel/telegram` and `intake/telegram` (long polling, buttons,
`ForceReply` for codes, a linking verb with a one-time deep-link code).
- The router keeps `channel/desktop`. An agent bridge consumes intake `message`s. The dead-man ping
goes in the self-check and in the watcher.
## The two tables
### Decisions × the capabilities they require
| Decision | Tier | choice | reply | verified-sender | exact-render | second-factor |
|---|---|---|---|---|---|---|
| `conditions silence` | acknowledge | ✓ | | ✓ | ✓ | |
| `retire approve` / `reject` (exact set) | approve | ✓ | | ✓ | ✓ | |
| confirm a binding move (`pin` for `binding-kept`) | approve | ✓ | | ✓ | ✓ | |
| `plans stop` / `close` | approve | ✓ | | ✓ | ✓ | |
| `push` by hand | approve | ✓ | | ✓ | ✓ | |
| `broker consumer-reset` | approve | ✓ | | ✓ | ✓ | |
| a healer's repair once more | approve | ✓ | | ✓ | ✓ | |
| `cleanup delete` (one, or an exact listed set) | destroy | ✓ | ✓ | ✓ | ✓ | ✓ |
| operator identities, away channel, factor enrolment | destroy | ✓ | ✓ | ✓ | ✓ | ✓ |
### Surfaces × the capabilities they declare
| Surface | deliver | reaches-away | loud | silent | edit | choice | reply | verified-sender | exact-render | second-factor | private | reaches-when-mesh-down | Can decide |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| telegram | ✓ | ✓ | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | | all tiers |
| desktop (dunst) | ✓ | | ✓ | ✓ | ✓ | | | | ✓ | | ✓ | | none, shows "decide on telegram" |
| ntfy | ✓ | ✓ | ✓ | ✓ | | | | | ✓ | | ~ | ~ (ntfy.sh) | none |
| matrix (own server) | ✓ | ~ | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ~ | | all tiers, once push is measured |
| pushover | ✓ | ✓ | ✓ | ✓ | | ~ (acknowledge) | | ✓ | ✓ | | | ✓ | acknowledge |
| mail (outside relay) | ✓ | ✓ | | ✓ | | | ✓ | | ✓ | ~ | | ✓ | none (break-glass with a code) |
| agent in a terminal | — | | | | | | ~ (relayed) | | | | | | none: requests only |
| agent through telegram | via the telegram holder | | | | | ✓ (holder's buttons) | ✓ | ✓ (holder's) | ✓ (holder renders) | ✓ | | | all tiers, in the same chat |
| console (a shell) | — | | | | | | | | ✓ (own output) | ✓ | | | break-glass with a code, recorded |
| the watcher's sender | ✓ | ✓ | | | | | | | | | | ✓ | none; outside the seats |
## The proposed record
> **Title.** Channels and intake are seats with declared capabilities, decisions are the controller's,
> and Telegram is their first holder.
>
> **Context.** The output channel was built in its minimal form (ADR 0227, to-be 45 §5): one holder
> carrying the router, a Telegram client and a desktop adapter, and no answering back. The operator
> expects many channels and inputs. The operator requires approving and rejecting from the away
> channel, and requires that nothing force a desk while working through it. Decisions today are verbs
> any granted principal can call, agents included, and a hand-act records the calling principal, not
> the person.
>
> **Considered options.**
> 1. Channels as contributions to the output seat (028 Q1 a). A channel is running code with its own
> secret and answers, not content a holder places.
> 2. One seat per channel kind. The router learns every seat.
> 3. Channel modules found by a manifest field. Callers use seats, never modules (ADR 0126).
> 4. One notifier with every channel built in. Shared failure, shared secrets, a release per channel.
> 5. A Telegram-specific module with its own approval path. Locks decisions to one service, and the
> next channel repeats it.
> 6. **Kinded benches `channel` and `intake`, a capability vocabulary, and decisions held by the
> controller. Chosen.**
>
> For answering:
> - a webhook (needs an inbound route; a stolen token redirects it) or **long polling (chosen)**;
> - decisions as direct verb calls by the channel module (the module decides who the operator is, and
> the record names the module) or **request and answer through the controller (chosen)**.
>
> **Decision.**
> - Two mesh seats are kinded benches: `channel` (send, edit, reply, standing) and `intake` (one
> envelope per input, emitted on the seat). Each holder is its own module, claims one kind, and
> declares capabilities from `channel-capabilities/1`. Each capability has a contract test and a
> drill, and a failed drill withdraws it.
> - The output seat's holder routes by required capability, verified presence and severity. It says
> when nothing can carry a message, and never degrades a decision.
> - The controller holds decisions: `decide request` (anyone; never performs), `decide answer` (intake
> holders only), `decisions`. It checks the caller's declared capabilities from its own records, the
> sender against its own list of the operator's identities, the exact state's digest, single use and
> expiry. It verifies destroy codes itself. It records `via`, `requested-by`, `decision` and `factor`
> on the hand-act.
> - Decisions are tiered acknowledge, approve and destroy, with the requirements in the table. The
> operator's away channel must satisfy every tier, checked by the self-check, unless the operator
> chose otherwise for a tier.
> - No agent decides. Decision-bearing verbs refuse direct calls except as break-glass with a code.
> - Telegram is the first holder of both seats and the away channel. The watcher's watcher stays
> outside the seats with its own bot. An outside dead-man service is pinged by the self-check and
> the watcher.
>
> **Consequences.**
> - The output seat's holder loses its Telegram client to a module of its own.
> - The catalogue gains `kind` and `capabilities` on a claim, and a second kind of bench.
> - The controller's verb definition gains `decision`.
> - `retire approve` takes the set it approves.
> - Agents' grants lose decision-bearing verbs.
> - To-be 45 §5 is amended: answering back exists.
> - Telegram sees the words of decisions, held to the content rule. It never sees a code's seed.
>
> **How it is checked.**
> - Catalogue tests: a claim with an unknown capability is refused; two holders of one kind are
> refused; each declared capability's contract test runs in the holder's build.
> - Controller tests, one per refusal:
> - a `decide answer` from a non-intake principal;
> - from a kind whose declared capabilities do not meet the tier;
> - from an identity not on the list;
> - with a stale state digest;
> - a second answer to one decision;
> - a destroy without a valid, unused code;
> - a direct `retire approve` without a code.
> - Self-check probe: the away channel meets every tier.
> - Live drill: request an approve and a destroy decision on a test condition, answer both on the phone,
> and read the hand-acts.
@@ -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.