approval_ux — come Metnos chiede conferma di un'azione criticaapproval_ux
approval_ux è lo schema visuale e di flusso con cui Metnos chiede a Roberto (e a chi è pairato come Supervised) di confermare un'azione critica prima di eseguirla. Vive nel canale di pairing — oggi Telegram, domani Signal e voce — e si attiva quando la policy calcola un effective_outcome uguale a approval_required.
Il pattern di base è quello del dialog manager: una carta a 3 righe, due bottoni inline (Approva / Rifiuta), un token opaco che identifica univocamente la richiesta. La carta viene modulata in funzione di quante volte la stessa richiesta è già stata posta — al decimo "vuoi che scarichi?" Roberto sa già cosa sta approvando e una riga compatta basta. Una concessione di territorio (per una sessione, in modo permanente) è segnalata con un marker testuale.
Sul retro della carta vive un'infrastruttura di tre componenti:
runtime/channels/approval.py) che produce l'OutboundMessage con testo e bottoni;runtime/approval_registry.py) che traccia ogni pending request con TTL e stato;runtime/channels/daemon.py + runtime/channels/telegram.py) che riceve i click sui bottoni e risolve il token.
Il pattern dal dialog manager prescrive tre righe canoniche, ognuna con una funzione precisa. La funzione render_approval_card (runtime/channels/approval.py:60-87) le compone a partire dai campi della ApprovalRequest:
| Riga | Funzione | Esempio |
|---|---|---|
| 1 | Domanda imperativa breve, costruita come f"Vuoi che {action_verb}?". Il verbo è alla seconda persona singolare, mai burocratico. |
Vuoi che scarichi? |
| 2 | Oggetto + sorgente -> destinazione + (~peso). Tutto quello che serve a Roberto per riconoscere a colpo d'occhio cosa sta facendo: file, URL di partenza, path di arrivo, dimensione approssimata. |
rapporto.pdf da drive.google.com -> ~/downloads/ (~2.4 MB) |
| 3 | Classificazione meta: reversibilità e classe di capability. Aiuta a distinguere a vista un'azione che si può annullare da una che è definitiva. | reversible | classe: fs_write:~/downloads/** |
Sotto le tre righe, due bottoni inline:
buttons = [[
{"text": "Approva", "data": f"approve:{req.token}"},
{"text": "Rifiuta", "data": f"reject:{req.token}"},
]]
Il token è il riferimento opaco emesso dal approval_registry al momento della creazione della pending request (vedi cap. 6); il data dei bottoni è l'unica informazione che tornerà indietro quando l'utente clicca, dunque il token deve essere sufficiente a identificare la richiesta.
La firma del rendering è minima:
def render_approval_card(req: ApprovalRequest) -> OutboundMessage: """Produce un OutboundMessage con la carta a 3 righe + due bottoni."""
Niente parametri di stile, niente i18n: tutto quello che varia è nei campi di ApprovalRequest (runtime/channels/approval.py:60-62).
ApprovalRequest
Il contratto fra chi solleva la richiesta (Vaglio, Policy, futuro pianificatore integrato) e il renderer è una dataclass frozen di otto campi. Citazione: runtime/channels/approval.py:27-38.
| Campo | Tipo | Cosa contiene |
|---|---|---|
action_verb | str | Il verbo all'imperativo seconda persona usato nella riga 1: scarichi, scriva, invii, … |
target_summary | str | La descrizione compatta della riga 2: oggetto + sorgente + destinazione + peso. |
capability_class | str | Classe di capability normalizzata (es. fs_write:~/downloads/**). Usata in riga 3 e per audit. |
reversibility | Literal["reversible","irreversible","partial"] | Quanto è recuperabile l'azione. Default reversible. Mostrato in riga 3. |
territory_concession | Literal["none","session","permanent"] | Se la conferma include una concessione di territorio (vedi cap. 5). Default none. |
recurrence_count | int | Numero di volte che una richiesta simile è già stata posta. Decide la forma della carta (vedi cap. 4). Default 0. |
token | str | Identificatore opaco emesso dal registry. Finisce nei callback_data dei bottoni. |
extra | dict | Campo libero per metadati che il chiamante vuole far seguire (es. ID di un file pendente, identificatore di sessione del pianificatore). Non viene letto dal renderer. |
La dataclass è frozen=True: una volta costruita, una ApprovalRequest non può essere modificata. Questo rende sicuro passarla fra moduli senza paura di mutazioni laterali, e si presta bene al hashing per deduplica futura.
action_verb in seconda persona singolare e non infinito. "Vuoi che scarichi?" suona come una persona che chiede; "Vuoi scaricare?" suona come un menu. La carta è una conversazione, non un dialogo modale: il verbo coniugato è un dettaglio che fa sentire Metnos come un agente che chiede, non un sistema che pretende.
La stessa richiesta posta dieci volte di fila non merita la stessa cura della prima. Il modulo prevede tre forme della carta, scelte in base al campo recurrence_count:
| Forma | Soglia | Quante righe | Forma del testo |
|---|---|---|---|
| Full | recurrence_count < 3 |
3 | Le tre righe canoniche complete: domanda + oggetto + meta. |
| Medium | 3 ≤ recurrence_count < 8 |
2 | Domanda+meta inline (riga 1) + oggetto (riga 2). La classificazione meta entra fra parentesi nella prima riga. |
| Short | recurrence_count ≥ 8 |
1 | Una riga compatta con verbo capitalizzato + oggetto + meta abbreviata fra parentesi quadre. |
La selezione è fatta da _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"
Le soglie sono in una costante a livello di modulo, dichiarata vicino al renderer:
_RECURRENCE_THRESHOLDS = (3, 8) # < 3: full | 3..7: medium | ≥ 8: short
Citazione: runtime/channels/approval.py:48-49. Cambiare le soglie è una modifica locale di una riga; la struttura del rendering non si tocca.
La forma medium e la forma short sono pensate per casi in cui Roberto sa cosa sta approvando: un agente che esegue una macro ricorrente, una sessione lunga in cui la stessa azione si ripete ogni minuto. La forma full resta il default per la prima volta e per le seconde volte: la cura di mostrare i dettagli è più importante della velocità di lettura.
recurrence_count arriva dal chiamante (Vaglio o Policy) ed è un input al renderer. Il renderer non tiene contatori. Quando la pipeline integrata sarà completa, il pianificatore consulterà il mnest (le tracce delle approvazioni passate) per calcolare il valore prima di costruire la ApprovalRequest.
Una concessione di territorio è quando, approvando, Roberto non solo dice "sí a questa azione" ma anche "sí a questa classe di azioni nel futuro". Tre livelli, tre marker testuali:
| Concessione | Significato | Marker |
|---|---|---|
none | Solo quest'azione, qui e ora. | (nessuno) |
session | Per il resto della sessione corrente, Metnos può eseguire questa classe senza chiedere. | [territorio: sessione] |
permanent | Da ora in poi, questa classe è concessa in modo persistente. | [territorio: permanente] |
Il marker viene appeso in coda alla classificazione meta. Citazione: runtime/channels/approval.py:42-46:
_TERRITORY_MARKER = {
"none": "",
"session": " [territorio: sessione]",
"permanent": " [territorio: permanente]",
}
È un dizionario, non una funzione: la mappatura è statica e non dipende da altro. ASCII robusto su tutti i client (niente glifi che variano da font a font). La parentesi quadra apre un blocco visivamente distinto dal resto della meta-riga, che usa la barra verticale come separatore. La concessione si vede a colpo d'occhio anche in una notifica push troncata.
approval_registry.resolve non scrive un grant in policy.py: la concessione di territorio è documentata in carta ma non rende ancora silenziose le richieste future. La pipeline completa (vedi cap. 10) le legherà.
Quando la policy decide che un'azione richiede approvazione, qualcuno deve tener traccia della pending request finché l'utente non risponde (o il TTL scade). Quel qualcuno è runtime/approval_registry.py: un modulo a sola SQLite, niente daemon, niente classi (a parte la dataclass del record).
Una sola tabella pending, primary key il token. Citazione: 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);
Quattro stati riconosciuti per la colonna status: pending, approved, rejected, expired. Le date sono ISO-8601 in UTC (%Y-%m-%dT%H:%M:%SZ), comode da confrontare lessicograficamente.
Cinque funzioni pubbliche. Tutte aprono e chiudono la connessione SQLite a ogni chiamata: niente connection pool, niente stato globale.
| Funzione | Cosa fa | Citazione |
|---|---|---|
create_pending(...) |
Genera un token UUID (16 hex), calcola scadenza (now + ttl_seconds), inserisce il record con status pending. Ritorna la PendingRequest appena creata. |
runtime/approval_registry.py:101-140 |
get_pending(token) |
Lookup per chiave. Ritorna PendingRequest o None. |
runtime/approval_registry.py:143-151 |
resolve(token, decision, *, by_channel, by_sender) |
Single-shot: applica una decisione (approved o rejected). Verifica che la richiesta sia ancora pending, non scaduta, e che il decisore sia lo stesso del richiedente. Solleva ApprovalError in ogni altro caso. |
runtime/approval_registry.py:154-210 |
cleanup_expired |
Marca come expired tutte le pending con expires_at < now. Ritorna il numero di righe affette. Da chiamare periodicamente da uno scheduler o da un comando CLI. |
runtime/approval_registry.py:213-227 |
list_pending(*, include_resolved=False, limit=50) |
Per dashboard e debug. Ordina per created_at DESC. |
runtime/approval_registry.py:230-250 |
Il path del DB è risolto in ordine: argomento esplicito db_path > variabile d'ambiente METNOS_APPROVALS_DB > default ~/.local/state/metnos/approvals.db. La directory viene creata se manca. Citazione: runtime/approval_registry.py:86-92.
Il TTL di default è 600 secondi (10 minuti). È il tempo entro cui Roberto deve rispondere prima che la richiesta venga marcata expired; un sistema di reminder o di re-issue rimane responsabilità del chiamante. Citazione: runtime/approval_registry.py:30.
Il punto di tensione è resolve. Quattro verifiche, in ordine, e un dettaglio importante sulla persistenza. Citazione: runtime/approval_registry.py:177-205:
ApprovalError("token sconosciuto").pending. Se è già risolto (approved/rejected/expired), ApprovalError("token già risolto"): niente double-spending della decisione.expires_at < now, viene scritto uno UPDATE che marca status='expired' e poi sollevato ApprovalError. La connessione è aperta in autocommit (isolation_level=None) proprio perché la transizione a expired deve sopravvivere all'eccezione.row["channel"] != by_channel o row["sender_id"] != by_sender, ApprovalError("decisore non autorizzato"): chi ha richiesto è chi può approvare. È l'asse identità descritto al cap. 5 dell'Architettura.
Solo dopo i quattro check, lo UPDATE finale scrive la decisione e ritorna la PendingRequest aggiornata.
Resta da spiegare come un click sul bottone "Approva" su Telegram diventi una chiamata a approval_registry.resolve. La catena attraversa tre componenti: il channel out (che ha disegnato i bottoni), il channel in (che riceve il callback), il dispatcher del daemon.
OutboundMessage.buttons è list[list[dict]]; ogni dict ha le chiavi text (etichetta visibile) e data (callback_data che tornerà al server). render_approval_card usa una sola riga di due bottoni:
buttons = [[
{"text": "Approva", "data": f"approve:{req.token}"},
{"text": "Rifiuta", "data": f"reject:{req.token}"},
]]
TelegramChannel.send traduce questa struttura in reply_markup.inline_keyboard nel formato Telegram (runtime/channels/telegram.py:108-114). I bottoni vengono cosí mostrati attaccati al messaggio della carta.
Quando l'utente clicca, Telegram non invia un nuovo messaggio: invia un oggetto callback_query nel prossimo getUpdates. TelegramChannel.poll lo riconosce esplicitamente. Citazione: 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
Tre dettagli da notare. (a) Il text dell'InboundMessage non è un testo umano: è il callback_data grezzo (approve:<tok> o reject:<tok>). (b) Il sender_id è preso da cbq.from.id (chi ha cliccato), con fallback alla chat originale: serve a verificare che chi clicca sia chi ha generato la richiesta. (c) Il marker extra={"kind": "callback"} distingue il messaggio dai testi normali quando il dispatcher decide come trattarlo. La chiamata finale a answerCallbackQuery è richiesta da Telegram per togliere la rotella di caricamento sul bottone cliccato; non manda contenuto, solo un acknowledgement.
ChannelDaemon.handle_message guarda extra.kind prima di tutto: se è callback, instrada al dispatcher dedicato. Citazione: runtime/channels/daemon.py:122-154 e runtime/channels/daemon.py:156-162:
def _handle_callback(self, msg: InboundMessage) -> dict:
"""Risolve un callback_query 'approve:<token>' o 'reject:<token>'."""
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}
La logica è lineare: parsing del prefisso approve: / reject:, estrazione del token, chiamata a resolve, conferma testuale all'utente. Eventuali ApprovalError (token già risolto, scaduto, decisore non autorizzato) si traducono in un messaggio di errore al sender.
handle_message il blocco if extra.kind == "callback" arriva prima della verifica del pairing (runtime/channels/daemon.py:161-162). Il motivo è che resolve è già più restrittivo del check di pairing: richiede non solo che il sender sia noto, ma che sia esattamente lo stesso che ha generato la pending request. Il check di pairing diventerebbe ridondante.
L'Architettura cap. 5 distingue tre assi della sicurezza: libertà/safety, identità/safety, perimetro/robustezza. Il flusso di approval mette in opera l'asse identità: ogni richiesta è etichettata col canale e il sender che l'hanno generata, e ogni risoluzione verifica l'identità del decisore prima di consumare il token. Un attaccante che riuscisse a sniffare un token e a usarlo da un altro chat ID non riuscirebbe a risolverlo: ApprovalError("decisore non autorizzato").
Tre raggruppamenti, tutti verdi alla data. I numeri puntano ai cluster del framework di test del runtime.
approval_registry (6/6)| # | Caso | Cosa verifica |
|---|---|---|
| 1 | create_pending_genera_token_e_record | create_pending ritorna una PendingRequest con token UUID, status pending, expires_at coerente con il TTL. |
| 2 | resolve_approve_aggiorna_status_e_decision | Una resolve con decision="approved" dal richiedente porta lo status a approved, valorizza decision_at e decision_by_*. |
| 3 | resolve_reject_simmetrico | Stesso percorso di (2) ma con decision="rejected". |
| 4 | resolve_token_sconosciuto_solleva | Token mai creato → ApprovalError("token sconosciuto"). |
| 5 | resolve_doppia_chiamata_e_idempotenza | Dopo una resolve, una seconda con lo stesso token solleva ApprovalError("token già risolto"): niente double-spending. |
| 6 | resolve_decisore_diverso_solleva_e_persistenza_expired | Decisore con sender_id diverso dal richiedente solleva ApprovalError("decisore non autorizzato"); richiesta scaduta viene marcata expired e l'UPDATE persiste anche dopo l'eccezione. |
channels — callback dispatcher (5/5)| # | Caso | Cosa verifica |
|---|---|---|
| 1 | handle_callback_approve_chiama_resolve_e_conferma | Inbound con extra.kind=callback e text approve:<tok> → resolve chiamata con "approved", messaggio di conferma "Approvato:..." inviato al sender. |
| 2 | handle_callback_reject_simmetrico | Stesso percorso ma con prefisso reject: → "rejected" + "Rifiutato:...". |
| 3 | handle_callback_data_malformato_logga_e_segnala | Callback senza prefisso noto → ritorno {"ok": False, "reason": "unknown_callback"}, log di warning. |
| 4 | handle_callback_approval_error_invia_errore_a_sender | Quando resolve solleva ApprovalError, il dispatcher invia un messaggio di errore al sender e ritorna {"ok": False, "reason": "approval_failed"}. |
| 5 | handle_callback_precede_check_pairing | Il dispatch del callback funziona anche per un sender_id non pairato: la sicurezza è già in resolve. |
| # | Caso | Cosa verifica |
|---|---|---|
| 1 | render_full_3_righe_con_marker_territorio | recurrence_count=0, territory_concession="permanent" → tre righe, marker [territorio: permanente] in coda alla terza, due bottoni con prefissi approve:/reject:. |
| 2 | render_medium_2_righe | recurrence_count=4 → due righe, classificazione meta inline nella prima. |
| 3 | render_short_1_riga | recurrence_count=10 → una riga compatta con verbo capitalizzato e meta abbreviata fra parentesi quadre. |
Tre esempi della stessa richiesta a tre stadi di ricorrenza. I bottoni sono renderizzati come pseudo-elementi sotto la carta.
recurrence_count=0, reversibility="reversible", territory_concession="none".
recurrence_count=3, stessa azione, stessa classe.
recurrence_count=8, stessa azione.
In tutti i casi i due bottoni hanno callback_data della forma approve:<tok> e reject:<tok>; il token è lo stesso che il approval_registry ha emesso quando la richiesta è stata creata. Quando l'utente clicca, il flusso descritto al cap. 7 si avvia: il callback_query arriva al daemon, _handle_callback chiama resolve, l'utente riceve la conferma testuale "Approvato: scarichi rapporto.pdf da drive.google.com -> ~/downloads/ (~2.4 MB)".
| Limite | Quando si toglie |
|---|---|
| Niente UI per CLI o voce. Oggi la carta esiste solo via Telegram. Un terminale interattivo o un canale voce richiederebbero un secondo renderer (per la voce, una sintesi parlata della stessa struttura logica). | Quando arriveranno i canali aggiuntivi previsti dall'astrazione channel: Signal API, voce via Piper/whisper.cpp. Il pattern di carta logica resta; cambia solo la traduzione finale. |
| Niente batching. Cinque pending request arrivano come cinque messaggi separati. Non c'è un digest serale né aggregazione per sender. | Quando il bother budget della policy diventerà più fine: una soglia oltre la quale le richieste non urgenti vengono accumulate e mostrate in un unico colpo. |
| Niente revoca della pending dal lato proponente. Una volta inviata, l'utente può solo approvare o rifiutare. Il pianificatore non può cancellare la richiesta a metà (per esempio se le condizioni sono cambiate). Il TTL di 10 minuti copre il caso peggiore. | Quando si introdurrà un cancel_pending(token, reason): scriverà uno status cancelled e invierà un messaggio di update al sender. Da pianificare insieme al refresh della policy. |
Niente persistenza grant automatica da approval. Oggi resolve non scrive un grant in policy.py: una concessione di territorio "permanente" approvata in carta non rende silenziose le richieste future della stessa classe. Il marker dichiara l'intenzione, non la implementa. |
Quando il pianificatore integrerà policy e approval_registry: la resolve chiamerà policy.grant(capability_class, scope=territory_concession) per scrivere il grant. Prossima iterazione, dopo la riscrittura di policy.html. |
Niente conta automatica delle ricorrenze. recurrence_count arriva dal chiamante. Il renderer non interroga il mnest. |
Quando il pianificatore integrato deciso al capitolo precedente sarà in piedi: prima di costruire la ApprovalRequest consulterà le tracce delle approvazioni passate per la stessa classe e calcolerà il valore. |
| Vaglio rule-based-v1. Il Vaglio usa il giudice rule-based-v1 di default con pattern vietati reali e un giudice LLM opzionale. L'infrastruttura di approval è pronta per richieste end-to-end dal pianificatore. | La pipeline entrerà in funzione end-to-end quando il pianificatore integrerà completamente i flussi di approval. |
approval_ux è un componente piccolo per linee di codice (renderer ~90 righe, registry ~280, dispatcher ~30 nel daemon) ma denso per significato: è il momento in cui Metnos si ferma e chiede. La cura della carta a 3 righe — verbo coniugato, oggetto leggibile, classificazione meta — non è estetica: è il modo in cui si rende possibile la decisione informata dell'utente in due secondi.
La regola del decisore = richiedente, scritta dentro resolve, è il punto fermo che permette al dispatcher dei callback di precedere il check di pairing senza creare buchi. Non si fida del dispatcher; si fida solo del registry. La sicurezza è concentrata nel posto giusto.