TESTED Microdesign v1.1 — new, tested on 27 April 2026. Cluster approval_registry 6/6 green + cluster channels approve/reject callback 5/5 green + 3 module cases for card rendering. References: runtime/channels/approval.py, runtime/approval_registry.py, runtime/channels/daemon.py, runtime/channels/telegram.py.
Status sequence: under approvalapprovedtestedimplemented. Replaces the obsolete v1.0 (~819 lines) written before the implementation.
← Documentation index Microdesign › approval_ux

Metnos

approval_ux — how Metnos asks for confirmation of a critical action
Microdesign v1.1 — status TESTED (27 April 2026)
Audience: anyone who wants to understand how a confirmation request is born, travels, and resolves.

Reading time: 12 minutes.

Index

  1. What approval_ux is
  2. The 3-line card
  3. The ApprovalRequest dataclass
  4. Recurrence-based modulation
  5. Territory marker
  6. SQLite approval registry
  7. Telegram callback dispatcher
  8. Tests
  9. End-to-end examples
  10. Limits and what is deferred to v1.2+

1. What approval_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:

v1.1 status. The infrastructure is tested and working: rendering, registry, dispatcher, identity-based security. The Vaglio in v1.1 is still an always-approve stub, hence real requests do not yet emerge from the planner. The pipeline is ready for when the real Vaglio starts raising approvals; in the meantime it is exercised through tests.

2. The 3-line card

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:

LineFunctionExample
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).

3. The 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.

FieldTypeWhat it contains
action_verbstrThe second-person imperative verb used in line 1: scarichi, scriva, invii, …
target_summarystrThe compact description of line 2: object + source + destination + size.
capability_classstrNormalised capability class (e.g. fs_write:~/downloads/**). Used in line 3 and for audit.
reversibilityLiteral["reversible","irreversible","partial"]How recoverable the action is. Default reversible. Shown in line 3.
territory_concessionLiteral["none","session","permanent"]Whether the confirmation includes a territory concession (see ch. 5). Default none.
recurrence_countintNumber of times a similar request has already been raised. Decides the form of the card (see ch. 4). Default 0.
tokenstrOpaque identifier issued by the registry. Ends up in the callback_data of the buttons.
extradictFree 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.

Why 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.

4. Recurrence-based modulation

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:

FormThresholdHow many linesForm 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.

Who counts the recurrences. In v1.1 the 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.

5. Territory marker

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:

ConcessionMeaningMarker
noneJust this action, here and now.(none)
sessionFor the rest of the current session, Metnos can execute this class without asking. [territorio: sessione]
permanentFrom 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.

No persistence in v1.1. The marker declares the intention of a concession, not its implementation. Today 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.

6. SQLite approval registry

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).

6.1 Schema

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.

6.2 API

Five public functions. All open and close the SQLite connection on every call: no connection pool, no global state.

FunctionWhat it doesReference
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

6.3 Database and TTL

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.

6.4 The security rule

The point of tension is resolve(). Four checks, in order, plus an important detail about persistence. Reference: runtime/approval_registry.py:177-205:

  1. Known token. If the token does not exist, ApprovalError("token sconosciuto").
  2. Status pending. If already resolved (approved/rejected/expired), ApprovalError("token già risolto"): no double-spending of the decision.
  3. Not expired. If 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.
  4. Decider = requester. If 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.

7. Telegram callback dispatcher

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.

7.1 Outbound buttons

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.

7.2 Incoming callback_query

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.

7.3 Dispatch in the daemon

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.

Callback dispatch precedes the pairing check. In 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.

7.4 The identity axis in practice

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").

8. Tests

Three groupings, all green as of 27 April 2026. The numbers point to clusters in the runtime test framework.

8.1 Cluster approval_registry (6/6)

#CaseWhat it verifies
1create_pending_genera_token_e_recordcreate_pending returns a PendingRequest with UUID token, pending status, expires_at consistent with the TTL.
2resolve_approve_aggiorna_status_e_decisionA resolve with decision="approved" from the requester transitions status to approved, sets decision_at and decision_by_*.
3resolve_reject_simmetricoSame path as (2) but with decision="rejected".
4resolve_token_sconosciuto_sollevaToken never created → ApprovalError("token sconosciuto").
5resolve_doppia_chiamata_e_idempotenzaAfter one resolve, a second one with the same token raises ApprovalError("token già risolto"): no double-spending.
6resolve_decisore_diverso_solleva_e_persistenza_expiredA 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.

8.2 Cluster channels — callback dispatcher (5/5)

#CaseWhat it verifies
1handle_callback_approve_chiama_resolve_e_confermaInbound with extra.kind=callback and text approve:<tok>resolve() called with "approved", "Approvato: ..." confirmation message sent to sender.
2handle_callback_reject_simmetricoSame path but with reject: prefix → "rejected" + "Rifiutato: ...".
3handle_callback_data_malformato_logga_e_segnalaCallback without known prefix → returns {"ok": False, "reason": "unknown_callback"}, warning log.
4handle_callback_approval_error_invia_errore_a_senderWhen resolve() raises ApprovalError, the dispatcher sends an error message to the sender and returns {"ok": False, "reason": "approval_failed"}.
5handle_callback_precede_check_pairingCallback dispatch works even for an unpaired sender_id: security is already in resolve().

8.3 Module cases — card rendering (3)

#CaseWhat it verifies
1render_full_3_righe_con_marker_territoriorecurrence_count=0, territory_concession="permanent" → three lines, marker [territorio: permanente] at the tail of the third, two buttons with approve:/reject: prefixes.
2render_medium_2_righerecurrence_count=4 → two lines, meta classification inline in the first.
3render_short_1_rigarecurrence_count=10 → one compact line with capitalised verb and abbreviated meta in square brackets.

9. End-to-end examples

Three examples of the same request at three recurrence stages. Buttons are rendered as pseudo-elements below the card.

Example 1 — Full form (first time)

recurrence_count=0, reversibility="reversible", territory_concession="none".

Vuoi che scarichi? rapporto.pdf da drive.google.com -> ~/downloads/ (~2.4 MB) reversible | classe: fs_write:~/downloads/**
ApprovaRifiuta
Example 2 — Medium form (fourth time)

recurrence_count=3, same action, same class.

Vuoi che scarichi? (reversible) rapporto.pdf da drive.google.com -> ~/downloads/ (~2.4 MB)
ApprovaRifiuta
Example 3 — Short form (eighth time)

recurrence_count=8, same action.

Scarichi rapporto.pdf da drive.google.com -> ~/downloads/ (~2.4 MB) [rev]?
ApprovaRifiuta

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)".

10. Limits and what is deferred to v1.2+

v1.1 limitWhen 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.

Final notes

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.