ADR-0014 — gmail_labels_modify in the transactional model: one multi-call act, a single consent¶
Date: 2026-10-01 Status: proposed (applies ADR-0013; opt-in feature, OFF by default) Deciders: Thomas (maintainer) Builds on: ADR-0013 · ADR-0012 · ADR-0011 · ADR-0007
In plain terms — The
gmail_labels_modifytool (PR #156) was not wired into transactional consent: it was refused as early as the internal read and did not offer the "for the session" choice. We wire it in. The specific difficulty: one tool call = several broker calls (list labels, create them, modify messages, modify threads) of two categories (readto resolve names,labelsto apply/remove). We therefore consent once for the whole act, then each sub-call is authorized under that consent. Inmanuel+sessionmode, the act writes one account-scoped grace(gmail, labels, "")that also covers the resolution read — never a broadgmail:readgrace. The tool verifies, the human authorizes, the LLM proposes.
Context¶
ADR-0013 set the "once / for the session" consent and wired it onto the Drive mutations (drive_create, drive_update, drive_copy, drive_upload). Each is one tool call = one broker call = one action.
gmail_labels_modify arrived afterwards (PR #156) and was never connected. Three symptoms, reproducible with the gmail-cleanerz CLI as well as with the agent in conversation:
- Read refused. The tool calls
labels.list(categorygmail.read) to resolve name → id. In transactional mode, the broker authorizes a call only through the consented capability carried by that call (ADR-0012, Zone 1), not through the session's persisted capabilities. Without threaded consent:read « gmail » outside session capabilities (gmail:read). - No more minutes-based unlock. ADR-0013 removed it;
session_unlock_in_conversationpoints to "for the session". The out-of-band escape hatch is closed. - No
grant_scope. The tool's MCP schema does not expose it; the act cannot carry the "for the session".
The hard point. Unlike a Drive mutation, gmail_labels_modify unfolds into several broker calls of two categories:
labels.list → read (resolving label names)
labels.create → labels (creating missing ones, if create_missing)
messages.batchModify → labels (apply/remove on messages)
threads.modify → labels (apply/remove, one call per thread)
Gating each sub-call separately would require several Touch IDs for one "apply a label". And since reading (read) and writes (labels) are distinct categories, a single labels grace would not suffice to cover the read: we must decide how a single consent covers both.
flowchart TD
A["gmail_labels_modify<br/>(one tool call)"] --> L["labels.list — read"]
A --> C["labels.create — labels"]
A --> M["messages/threads modify — labels"]
L --> G{"act already consented?<br/>(act object, or session grace)"}
C --> G
M --> G
G -->|yes| P["no action →<br/>consented_cap of the sub-call's category"]
G -->|no| D["ONE action for the act<br/>(mutation: prompt = labels + scope)"]
D --> P
D -->|"manuel + session"| GR["writes ONE grace<br/>(gmail, labels, '')"]
GR --> P
style D fill:#fff4ce,stroke:#9a7d0a
style P fill:#cfe8ff,stroke:#1b6fb3
style GR fill:#e8f5e9,stroke:#2e7d32
Figure 1 — The sub-calls of a single act converge on one decision point. The first to arrive (the resolution read) triggers THE action of the act; the following ones pass without action. In manuel + session, an account-scoped grace is written, which will also cover the resolution read of subsequent acts.
Decision 1 — A multi-call act, a single consent, via an act object¶
Decision. gmail_labels_modify creates an act object (a Python dict, {"granted": False, "summary": …}) and threads it, along with grant_scope, into each of its _run sub-calls. The transactional gate (_transactional_gate) recognizes a label-act sub-call and routes it to _gmail_label_act_gate. This gate:
- if the act is already consented (
granted == True, set by a previous sub-call of the same act) → authorizes without action; - otherwise, if a grace
(gmail, labels, "")already covers the mailbox (manuelmode) → marks the act consented, authorizes without action; - otherwise → one signed action for the entire act (curation is a mutation → action in
autoas inmanuel), marks the act consented, then — ifmanuel+session— writes the grace.
Each sub-call receives the consented_cap of its category: read for labels.list, labels for writes. The broker re-verifies each one (Zone 1) — unchanged.
Why an (in-memory) act object and not just the grace. The grace lives only in manuel + session mode. For grant_scope=once (the default) and auto mode to also cost a single action per act, "the act is consented" must be remembered for the duration of the call, persisting nothing. The act object is that memory: it is born and dies with the gmail_labels_modify call, is never exposed in the MCP schema (the client cannot fabricate it), and the gate honors it only for a gmail sub-call of category read or labels (upstream guard; everything else falls back to the normal gate).
Alternative rejected. Gate each sub-call independently (as for Drive). Rejected: several Touch IDs for one act — exactly the "unusable" observed.
Fail-closed fallback. Act object absent → normal gate (one action per sub-call). Category outside {read, labels} on an act sub-call → normal gate (never the act shortcut). Action refused → GatewayError(code="locked"), no target modified.
Decision 2 — The curation grace is account-scoped, and covers the resolution read¶
Decision. In manuel + session, the act writes a single grace session_grant_capability_session_lived(sid, alias, "gmail", "labels", "") — empty resource (account-scoped). session_has_capability already treats a capability without a resource as covering every service × operation of that account. The scope of a label curation IS the mailbox: there is no finer resource that is stable (a messages modify carries addLabelIds and removeLabelIds → ambiguous operand, excluded from _OPERAND_PARAM on purpose since backlog item 0080).
This single labels grace also covers the act's labels.list read: _gmail_label_act_gate authorizes the read sub-call as soon as the act is consented (grace present or action done). We therefore never persist a gmail:read grace. This is the "act-bound sub-grant": the read is subordinate to the curation act, not a standalone Gmail read access.
Why account-scoped is safe here (and refused for Drive). For Drive, a write with no derivable resource falls back to "once" (ADR-0013 Decision 2): a service-wide Drive grace would be too broad. For labels, account scope is the right scope, and it remains doubly bounded:
- by the
gmail.labelsaccount policy (ADR-0007 intersection, unchanged); - by the "never a system label" safeguard —
gmail_labels_modifyrefuses TRASH/SPAM/INBOX… before any call, andcategorize.gmail_labels_overridereclassifies amodifytouching a system label asdelete/update(therefore outsidelabels), so the(gmail, labels)consented_capwould not authorize it at the broker.
The grace therefore loosens only the curation of user labels — never a send, a deletion, a trash, a share.
Alternative rejected. Persist a (gmail, read, "") grace to cover resolution. Rejected: such a grace would authorize any Gmail read in the session (messages, contents) — far beyond name resolution. The act-bound sub-grant gives exactly what is needed, no more.
Fail-closed fallback. Outside manuel (auto mode) or delegated sub-session → no grace; scope normalized to once (honest receipt). Grace write failure → the already-approved act does not fail (safety net, as in ADR-0013 batch 6).
Decision 3 — grant_scope at the edge, like the Drive mutations¶
Decision. grant_scope (enum once/session, default once) is exposed in the MCP schema of gmail_labels_modify (_GRANT_SCOPE_PROPERTY), extracted at dispatch, added to the signature of api.gmail_labels_modify, and threaded into its _run calls. Never on sharing or drafts (ADR-0013 Decision 4 safeguard, unchanged). The LLM fills in this parameter when the user says "for the session"; the human reads the scope in the Touch ID prompt and confirms.
Consequence. Ergonomics join Drive's: a single parameter, a signed receipt naming the act (gmail:users:labels:modify), the labels applied/removed and their number of targets, and the chosen scope.
Fail-closed fallback (summary)¶
- Transactional flag OFF → behavior as before, bit for bit (the act object is ignored).
- Scope absent/unknown →
once.automode / sub-session →sessionnormalized toonce, no grace. - Act object absent, or sub-call outside
gmail:{read,labels}→ normal gate (one action per sub-call). - System label → refused upstream AND reclassified outside
labels→ not authorized by the act's cap. - Action refused, cap unreadable, lock failing → refusal, never access; no target modified.
- Sharing and drafts have neither
grant_scopenor a grace.
Consequences¶
- Usable. Applying
gc/to-deleteto a thread costs one "for the session" action; subsequent acts in the session, zero. The resolution read is covered with no separate grant. - Safe. The only thing loosened "for the session" is the curation of user labels on a mailbox, bounded by policy + system guard. No broad
gmail:readgrace exists. - Debt-free. Reuses ADR-0013's plumbing (
session_grant_capability_session_lived,session_has_capability,_consented_cap,run_elicitation_gate,categorize); no duplicated source of truth; the broker is unchanged.
References¶
- ADRs: ADR-0013 (applied here), ADR-0012, ADR-0011, ADR-0007.
- Backlog item:
features/20261001214857000_gmail-labels-modify-transactionnel.md. - Code:
gateway/api.py(gmail_labels_modify,_run,_transactional_gate,_gmail_label_act_gate,_consented_cap,_is_delegated_session),gateway/sessions.py(session_grant_capability_session_lived,session_has_capability),gateway/categorize.py(categorize,gmail_labels_override,operand_resource),gateway/mcp_server.py(_GRANT_SCOPE_PROPERTY, schema + dispatch),gateway/broker_server.py(Zone 1 — unchanged).