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:
@@ -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.
|
||||
Reference in New Issue
Block a user