approval_ux — how Metnos asks for confirmation of a critical actionapproval_ux is
approval_ux is the visual and flow schema with which Metnos asks Roberto (and anyone paired as Supervised) to confirm a critical action before executing it. It lives on the pairing channel — today Telegram, tomorrow Signal and voice — and triggers when the policy computes an effective_outcome equal to approval_required.
The base pattern is the dialog manager pattern: a 3-line card, two inline buttons (Approve / Reject), an opaque token that uniquely identifies the request. The card is modulated as a function of how many times the same request has already been issued — at the tenth "may I download?" Roberto already knows what he is approving and a compact line is enough. A territory concession (for a session, permanently) is signalled with a textual marker.
Behind the card lives an infrastructure of three components:
runtime/channels/approval.py) which produces the OutboundMessage with text and buttons;runtime/approval_registry.py) which tracks every pending request with TTL and status;runtime/channels/daemon.py + runtime/channels/telegram.py) which receives the button clicks and resolves the token.
The dialog manager pattern prescribes three canonical lines, each with a precise function. The render_approval_card function (runtime/channels/approval.py:60-87) composes them from the fields of ApprovalRequest:
| Line | Function | Example |
|---|---|---|
| 1 | Short imperative question, built as f"May I {action_verb}?" (or in IT f"Vuoi che {action_verb}?"). The verb is in second-person singular, never bureaucratic. |
May I download? |
| 2 | Object + source -> destination + (~size). Everything Roberto needs to recognise at a glance what is happening: file, source URL, destination path, approximate size. |
report.pdf from drive.google.com -> ~/downloads/ (~2.4 MB) |
| 3 | Meta classification: reversibility and capability class. Helps to distinguish at a glance an action that can be undone from one that is final. | reversible | class: fs_write:~/downloads/** |
Below the three lines, two inline buttons:
buttons = [[
{"text": "Approva", "data": f"approve:{req.token}"},
{"text": "Rifiuta", "data": f"reject:{req.token}"},
]]
The token is the opaque reference issued by the approval_registry when the pending request was created (see ch. 6); the data of the buttons is the only information that will come back when the user clicks, hence the token must be enough to identify the request.
The rendering signature is minimal:
def render_approval_card(req: ApprovalRequest) -> OutboundMessage:
"""Produces an OutboundMessage with the 3-line card + two buttons."""
No styling parameters, no i18n: everything that varies is in the ApprovalRequest fields (runtime/channels/approval.py:60-62).
ApprovalRequest dataclass
The contract between the request raiser (Vaglio, Policy, future integrated planner) and the renderer is a frozen dataclass of eight fields. Reference: runtime/channels/approval.py:27-38.
| Field | Type | What it contains |
|---|---|---|
action_verb | str | The second-person imperative verb used in line 1: scarichi, scriva, invii, … |
target_summary | str | The compact description of line 2: object + source + destination + size. |
capability_class | str | Normalised capability class (e.g. fs_write:~/downloads/**). Used in line 3 and for audit. |
reversibility | Literal["reversible","irreversible","partial"] | How recoverable the action is. Default reversible. Shown in line 3. |
territory_concession | Literal["none","session","permanent"] | Whether the confirmation includes a territory concession (see ch. 5). Default none. |
recurrence_count | int | Number of times a similar request has already been raised. Decides the form of the card (see ch. 4). Default 0. |
token | str | Opaque identifier issued by the registry. Ends up in the callback_data of the buttons. |
extra | dict | Free field for metadata the caller wants to carry through (e.g. ID of a pending file, planner session ID). Not read by the renderer. |
The dataclass is frozen=True: once built, an ApprovalRequest cannot be modified. This makes it safe to pass between modules without fear of side mutations, and lends itself well to hashing for future deduplication.
action_verb in second-person singular and not infinitive. "May I download?" reads as a person asking; "Want to download?" reads as a menu. The card is a conversation, not a modal dialog: the conjugated verb is a detail that makes Metnos feel like an agent that asks, not a system that demands.
The same request raised ten times in a row does not deserve the same care as the first. The module foresees three forms of the card, chosen based on the recurrence_count field:
| Form | Threshold | How many lines | Form of the text |
|---|---|---|---|
| Full | recurrence_count < 3 |
3 | The three full canonical lines: question + object + meta. |
| Medium | 3 ≤ recurrence_count < 8 |
2 | Question+meta inline (line 1) + object (line 2). The meta classification enters parenthesised in the first line. |
| Short | recurrence_count ≥ 8 |
1 | One compact line with capitalised verb + object + abbreviated meta in square brackets. |
Selection is done by _shape_for_recurrence (runtime/channels/approval.py:52-57):
def _shape_for_recurrence(n: int) -> Literal["full", "medium", "short"]:
if n >= _RECURRENCE_THRESHOLDS[1]:
return "short"
if n >= _RECURRENCE_THRESHOLDS[0]:
return "medium"
return "full"
The thresholds are in a module-level constant, declared next to the renderer:
_RECURRENCE_THRESHOLDS = (3, 8) # < 3: full | 3..7: medium | ≥ 8: short
Reference: runtime/channels/approval.py:48-49. Changing the thresholds is a one-line local edit; the rendering structure is untouched.
Medium and short forms are designed for cases where Roberto knows what he is approving: an agent running a recurring macro, a long session in which the same action repeats every minute. Full remains the default for the first time and the second time: the care of showing details matters more than reading speed.
recurrence_count arrives from the caller (Vaglio or Policy) and is an input to the renderer. The renderer holds no counters. When the integrated pipeline is complete, the planner will consult the mnest (the traces of past approvals) to compute the value before constructing the ApprovalRequest.
A territory concession is when, by approving, Roberto says not only "yes to this action" but also "yes to this class of actions in the future". Three levels, three textual markers:
| Concession | Meaning | Marker |
|---|---|---|
none | Just this action, here and now. | (none) |
session | For the rest of the current session, Metnos can execute this class without asking. | [territorio: sessione] |
permanent | From now on, this class is granted persistently. | [territorio: permanente] |
The marker is appended at the tail of the meta classification. Reference: runtime/channels/approval.py:42-46:
_TERRITORY_MARKER = {
"none": "",
"session": " [territorio: sessione]",
"permanent": " [territorio: permanente]",
}
It is a dictionary, not a function: the mapping is static and depends on nothing else. Robust ASCII on every client (no glyphs that vary from font to font). The square bracket opens a block visually distinct from the rest of the meta line, which uses the vertical bar as separator. The concession is visible at a glance even in a truncated push notification.
approval_registry.resolve() does not write a grant in policy.py: the territory concession is documented on the card but does not yet silence future requests. The full pipeline (see ch. 10) will tie them together.
When the policy decides that an action requires approval, someone has to track the pending request until the user responds (or the TTL expires). That someone is runtime/approval_registry.py: a SQLite-only module, no daemon, no classes (apart from the record dataclass).
A single table pending, primary key the token. Reference: runtime/approval_registry.py:34-52:
CREATE TABLE IF NOT EXISTS pending (
token TEXT PRIMARY KEY,
channel TEXT NOT NULL,
sender_id TEXT NOT NULL,
capability_class TEXT NOT NULL,
action_verb TEXT NOT NULL,
target_summary TEXT NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
decision_at TEXT,
decision_by_channel TEXT,
decision_by_sender TEXT,
request_extra TEXT
);
CREATE INDEX IF NOT EXISTS idx_pending_status ON pending(status);
CREATE INDEX IF NOT EXISTS idx_pending_expires ON pending(expires_at);
Four recognised states for the status column: pending, approved, rejected, expired. Dates are ISO-8601 in UTC (%Y-%m-%dT%H:%M:%SZ), convenient for lexicographic comparison.
Five public functions. All open and close the SQLite connection on every call: no connection pool, no global state.
| Function | What it does | Reference |
|---|---|---|
create_pending(...) |
Generates a UUID token (16 hex), computes expiry (now + ttl_seconds), inserts the record with pending status. Returns the freshly created PendingRequest. |
runtime/approval_registry.py:101-140 |
get_pending(token) |
Lookup by key. Returns PendingRequest or None. |
runtime/approval_registry.py:143-151 |
resolve(token, decision, *, by_channel, by_sender) |
Single-shot: applies a decision (approved or rejected). Verifies that the request is still pending, not expired, and that the decider is the same as the requester. Raises ApprovalError in any other case. |
runtime/approval_registry.py:154-210 |
cleanup_expired() |
Marks as expired all pending entries with expires_at < now. Returns the number of affected rows. To be called periodically by a scheduler or a CLI command. |
runtime/approval_registry.py:213-227 |
list_pending(*, include_resolved=False, limit=50) |
For dashboard and debug. Sorts by created_at DESC. |
runtime/approval_registry.py:230-250 |
The DB path is resolved in order: explicit argument db_path > environment variable METNOS_APPROVALS_DB > default ~/.local/state/metnos/approvals.db. The directory is created if missing. Reference: runtime/approval_registry.py:86-92.
The default TTL is 600 seconds (10 minutes). It is the time within which Roberto must respond before the request is marked expired; a reminder system or a re-issue remains the caller's responsibility. Reference: runtime/approval_registry.py:30.
The point of tension is resolve(). Four checks, in order, plus an important detail about persistence. Reference: runtime/approval_registry.py:177-205:
ApprovalError("token sconosciuto").pending. If already resolved (approved/rejected/expired), ApprovalError("token già risolto"): no double-spending of the decision.expires_at < now, an UPDATE is written that marks status='expired' and then ApprovalError is raised. The connection is opened in autocommit (isolation_level=None) precisely because the transition to expired must survive the exception.row["channel"] != by_channel or row["sender_id"] != by_sender, ApprovalError("decisore non autorizzato"): who requested is who can approve. This is the identity axis described in ch. 5 of the Architecture.
Only after the four checks does the final UPDATE write the decision and return the updated PendingRequest.
What remains to explain is how a click on the "Approva" button on Telegram becomes a call to approval_registry.resolve(). The chain crosses three components: the channel out (which drew the buttons), the channel in (which receives the callback), the daemon dispatcher.
OutboundMessage.buttons is list[list[dict]]; each dict has the keys text (visible label) and data (callback_data that will return to the server). render_approval_card uses a single row of two buttons:
buttons = [[
{"text": "Approva", "data": f"approve:{req.token}"},
{"text": "Rifiuta", "data": f"reject:{req.token}"},
]]
TelegramChannel.send translates this structure into reply_markup.inline_keyboard in Telegram format (runtime/channels/telegram.py:108-114). The buttons are thus shown attached to the card message.
When the user clicks, Telegram does not send a new message: it sends a callback_query object in the next getUpdates. TelegramChannel.poll recognises it explicitly. Reference: runtime/channels/telegram.py:135-156:
cbq = u.get("callback_query")
if cbq:
msg_ctx = cbq.get("message") or {}
out.append(InboundMessage(
channel=self.name,
sender_id=str(cbq.get("from", {}).get("id")
or msg_ctx.get("chat", {}).get("id", "")),
text=cbq.get("data", ""),
message_id=str(msg_ctx.get("message_id", "")),
received_at=float(msg_ctx.get("date", time.time())),
extra={"kind": "callback", "callback_id": cbq.get("id"),
"from": cbq.get("from", {}), "update_id": uid},
))
cb_id = cbq.get("id")
if cb_id:
self._call("answerCallbackQuery", {"callback_query_id": cb_id})
continue
Three details to note. (a) The text of the InboundMessage is not human text: it is the raw callback_data (approve:<tok> or reject:<tok>). (b) The sender_id is taken from cbq.from.id (who clicked), with fallback to the original chat: it serves to verify that the clicker is the one who generated the request. (c) The marker extra={"kind": "callback"} distinguishes the message from regular texts when the dispatcher decides how to treat it. The final call to answerCallbackQuery is required by Telegram to remove the loading spinner on the clicked button; it sends no content, just an acknowledgement.
ChannelDaemon.handle_message looks at extra.kind first: if it is callback, it routes to the dedicated dispatcher. Reference: runtime/channels/daemon.py:122-154 and runtime/channels/daemon.py:156-162:
def _handle_callback(self, msg: InboundMessage) -> dict:
"""Resolves an 'approve:<token>' or 'reject:<token>' callback_query."""
data = (msg.text or "").strip()
if data.startswith("approve:"):
decision = "approved"
token = data[len("approve:"):]
user_label = "Approvato"
elif data.startswith("reject:"):
decision = "rejected"
token = data[len("reject:"):]
user_label = "Rifiutato"
else:
log.warning("callback_data non riconosciuto: %r", data)
return {"ok": False, "reason": "unknown_callback", "data": data}
if not token:
return {"ok": False, "reason": "missing_token"}
try:
rec = approval_registry.resolve(
token, decision,
by_channel=self.channel.name, by_sender=msg.sender_id,
)
except approval_registry.ApprovalError as e:
self._send_text(msg.sender_id,
f"Approval non risolvibile: {e}",
reply_to=msg.message_id)
return {"ok": False, "reason": "approval_failed", "error": str(e)}
log.info("approval risolto: token=%s decision=%s sender=%s",
token, decision, msg.sender_id)
self._send_text(msg.sender_id,
f"{user_label}: {rec.action_verb} {rec.target_summary}",
reply_to=msg.message_id)
return {"ok": True, "decision": decision, "token": token,
"capability_class": rec.capability_class}
The logic is linear: parsing of the approve: / reject: prefix, extraction of the token, call to resolve(), textual confirmation to the user. Any ApprovalError (token already resolved, expired, decider not authorised) is translated into an error message to the sender.
handle_message, the if extra.kind == "callback" block comes before the pairing check (runtime/channels/daemon.py:161-162). The reason is that resolve() is already more restrictive than the pairing check: it requires not only that the sender be known, but that it be exactly the same as the one who generated the pending request. The pairing check would become redundant.
Architecture ch. 5 distinguishes three security axes: freedom/safety, identity/safety, perimeter/robustness. The approval flow puts the identity axis to work: every request is tagged with the channel and sender that generated it, and every resolution verifies the decider's identity before consuming the token. An attacker who managed to sniff a token and use it from another chat ID would not be able to resolve it: ApprovalError("decisore non autorizzato").
Three groupings, all green as of 27 April 2026. The numbers point to clusters in the runtime test framework.
approval_registry (6/6)| # | Case | What it verifies |
|---|---|---|
| 1 | create_pending_genera_token_e_record | create_pending returns a PendingRequest with UUID token, pending status, expires_at consistent with the TTL. |
| 2 | resolve_approve_aggiorna_status_e_decision | A resolve with decision="approved" from the requester transitions status to approved, sets decision_at and decision_by_*. |
| 3 | resolve_reject_simmetrico | Same path as (2) but with decision="rejected". |
| 4 | resolve_token_sconosciuto_solleva | Token never created → ApprovalError("token sconosciuto"). |
| 5 | resolve_doppia_chiamata_e_idempotenza | After one resolve, a second one with the same token raises ApprovalError("token già risolto"): no double-spending. |
| 6 | resolve_decisore_diverso_solleva_e_persistenza_expired | A decider with sender_id different from the requester raises ApprovalError("decisore non autorizzato"); an expired request is marked expired and the UPDATE persists even after the exception. |
channels — callback dispatcher (5/5)| # | Case | What it verifies |
|---|---|---|
| 1 | handle_callback_approve_chiama_resolve_e_conferma | Inbound with extra.kind=callback and text approve:<tok> → resolve() called with "approved", "Approvato: ..." confirmation message sent to sender. |
| 2 | handle_callback_reject_simmetrico | Same path but with reject: prefix → "rejected" + "Rifiutato: ...". |
| 3 | handle_callback_data_malformato_logga_e_segnala | Callback without known prefix → returns {"ok": False, "reason": "unknown_callback"}, warning log. |
| 4 | handle_callback_approval_error_invia_errore_a_sender | When resolve() raises ApprovalError, the dispatcher sends an error message to the sender and returns {"ok": False, "reason": "approval_failed"}. |
| 5 | handle_callback_precede_check_pairing | Callback dispatch works even for an unpaired sender_id: security is already in resolve(). |
| # | Case | What it verifies |
|---|---|---|
| 1 | render_full_3_righe_con_marker_territorio | recurrence_count=0, territory_concession="permanent" → three lines, marker [territorio: permanente] at the tail of the third, two buttons with approve:/reject: prefixes. |
| 2 | render_medium_2_righe | recurrence_count=4 → two lines, meta classification inline in the first. |
| 3 | render_short_1_riga | recurrence_count=10 → one compact line with capitalised verb and abbreviated meta in square brackets. |
Three examples of the same request at three recurrence stages. Buttons are rendered as pseudo-elements below the card.
recurrence_count=0, reversibility="reversible", territory_concession="none".
recurrence_count=3, same action, same class.
recurrence_count=8, same action.
In all cases the two buttons have callback_data of the form approve:<tok> and reject:<tok>; the token is the same one the approval_registry issued when the request was created. When the user clicks, the flow described in ch. 7 starts: the callback_query reaches the daemon, _handle_callback calls resolve(), the user receives the textual confirmation "Approvato: scarichi rapporto.pdf da drive.google.com -> ~/downloads/ (~2.4 MB)".
| v1.1 limit | When it is removed |
|---|---|
| No CLI or voice UI. Today the card exists only via Telegram. An interactive terminal or a voice channel would require a second renderer (for voice, a spoken synthesis of the same logical structure). | When the additional channels foreseen by the channel abstraction arrive: Signal API, voice via Piper/whisper.cpp. The logical-card pattern remains; only the final translation changes. |
| No batching. Five pending requests arrive as five separate messages. There is no evening digest nor per-sender aggregation. | When the bother budget of the policy becomes finer: a threshold beyond which non-urgent requests are accumulated and shown in a single shot. |
| No revocation of the pending from the proposer side. Once sent, the user can only approve or reject. The planner cannot cancel the request mid-flight (for instance if conditions changed). The 10-minute TTL covers the worst case. | When a cancel_pending(token, reason) is introduced: it will write a cancelled status and send an update message to the sender. To be planned together with the refresh of the policy. |
No automatic grant persistence from approval. Today resolve() does not write a grant in policy.py: a "permanent" territory concession approved on the card does not silence future requests of the same class. The marker declares the intention, not the implementation. |
When the planner integrates policy and approval_registry: resolve() will call policy.grant(capability_class, scope=territory_concession) to write the grant. Next iteration, after the rewrite of policy.html v1.1. |
No automatic recurrence count. recurrence_count arrives from the caller. The renderer does not query the mnest. |
When the integrated planner decided in the previous chapter is in place: before constructing the ApprovalRequest it will consult the traces of past approvals for the same class and compute the value. |
| No real Vaglio. In v1.1 the Vaglio is an always-approve stub: the approval infrastructure exists, but real requests are not yet generated by the planning flow. | Phase 5 of the roadmap: release of the graded Vaglio that computes effective_outcome and raises approval_required when foreseen. At that point the pipeline described here will enter end-to-end operation in the real planner. |
approval_ux is a small component by lines of code (renderer ~90 lines, registry ~280, dispatcher ~30 in the daemon) but dense in meaning: it is the moment when Metnos pauses and asks. The care of the 3-line card — conjugated verb, readable object, meta classification — is not aesthetic: it is the way an informed user decision is made possible in two seconds.
The decider = requester rule, written inside resolve(), is the fixed point that allows the callback dispatcher to precede the pairing check without creating holes. It does not trust the dispatcher; it trusts only the registry. Security is concentrated in the right place.