From e3434d19ea0bc940e407b6b52d6a65f0492916c3 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Wed, 16 Sep 2026 23:28:53 -0400 Subject: [PATCH] fix(ai): garantir une reponse finale quand la boucle d'agent epuise son budget (BUG-052) --- CHANGELOG.md | 18 +++++- README.fr.md | 6 +- README.md | 6 +- VERSION | 2 +- backend/agent/loop.py | 102 +++++++++++++++++++++++++----- desktop/Cargo.lock | 2 +- desktop/Cargo.toml | 2 +- desktop/tauri.conf.json | 2 +- docs/ISSUES_TODOLIST.md | 2 + docs/ROADMAP.md | 2 +- docs/features/ai-tools-roadmap.md | 5 ++ package.json | 2 +- tests/test_agent_loop.py | 34 ++++++++++ 13 files changed, 155 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f21c02a..36577ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). > **En cours de développement** : les changements à venir sont listés dans la section -> [Unreleased](#unreleased). La dernière version livrée est **2.7.2**. +> [Unreleased](#unreleased). La dernière version livrée est **2.7.3**. --- @@ -14,6 +14,22 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.7.3] — 2026-09-16 + +### Corrigé + +- **BUG-052 - Assistant IA : recherche web sans réponse finale (étapes et sources affichées, aucun texte)** : + quand le budget d'itérations (`DEFAULT_MAX_ITERATIONS = 10`) ou le quota d'appels d'outils + était épuisé pendant que le modèle enchaînait encore des recherches/lectures, la boucle + d'agent renvoyait un contenu vide → la conversation n'affichait que les étapes et les + sources. La boucle effectue désormais un **dernier appel sans outil** qui demande au modèle + de synthétiser les informations recueillies (`_finalize_answer`), avec un repli déterministe + listant les sources si cet appel échoue ou reste vide. Les appels d'outils non atteints du + lot en cours de quota reçoivent un résultat `deferred` pour garder la conversation valide. + Fichier : `backend/agent/loop.py`. Tests : `tests/test_agent_loop.py` (+2). + +--- + ## [2.7.2] — 2026-09-16 ### Corrigé diff --git a/README.fr.md b/README.fr.md index 476ded7..9137722 100644 --- a/README.fr.md +++ b/README.fr.md @@ -4,7 +4,7 @@ **Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive. -[![Version](https://img.shields.io/badge/Version-2.7.2-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.7.3-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -916,8 +916,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l ## 📝 Changelog -Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.7.2). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.7.3). --- -*Projet : ObsiGate | Version : 2.7.2 | Dernière mise à jour : Juin 2026* +*Projet : ObsiGate | Version : 2.7.3 | Dernière mise à jour : Juin 2026* diff --git a/README.md b/README.md index 7b32b9c..3365f22 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface. -[![Version](https://img.shields.io/badge/Version-2.7.2-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.7.3-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -1085,8 +1085,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) ## 📝 Changelog -See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.7.2). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.7.3). --- -*Project: ObsiGate | Version: 2.7.2 | Last updated: May 2026* +*Project: ObsiGate | Version: 2.7.3 | Last updated: May 2026* diff --git a/VERSION b/VERSION index 37c2961..2c9b4ef 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.7.2 +2.7.3 diff --git a/backend/agent/loop.py b/backend/agent/loop.py index 4ca94b6..088ec65 100644 --- a/backend/agent/loop.py +++ b/backend/agent/loop.py @@ -40,6 +40,15 @@ MAX_TOOL_RESULT_CHARS = 100_000 # Quota: maximum tool calls executed per agent run (``BOOKSLM_MAX_TOOL_CALLS``). DEFAULT_MAX_TOOL_CALLS = int(os.environ.get("BOOKSLM_MAX_TOOL_CALLS", "25")) +# Sent as a last user turn when the loop stopped before the model produced an +# answer (iteration/quota budget exhausted while it was still calling tools). +_FINALIZE_INSTRUCTION = ( + "N'appelle plus aucun outil. Réponds maintenant directement à l'utilisateur, " + "en français, à partir des informations déjà recueillies ci-dessus. " + "Structure la réponse en Markdown, cite les liens sources utiles, et si les " + "informations sont insuffisantes, dis-le explicitement." +) + # Stopping reasons STOP_DONE = "done" STOP_MAX_ITERATIONS = "max_iterations" @@ -109,8 +118,8 @@ def _assistant_tool_message(content: str | None, tool_calls: list[Any]) -> dict[ } -def _deferred_tool_message(call: Any) -> dict[str, Any]: - """Answer a tool call that was not reached because the run paused. +def _deferred_tool_message(call: Any, reason: str | None = None) -> dict[str, Any]: + """Answer a tool call that was not reached because the run stopped early. A single LLM response may carry several tool calls. When one of them is mutating and pauses the run for confirmation, the assistant message already @@ -125,7 +134,7 @@ def _deferred_tool_message(call: Any) -> dict[str, Any]: "name": call.name, "content": json.dumps({ "status": "deferred", - "reason": ( + "reason": reason or ( "Not executed: the run paused to confirm an earlier tool call. " "Re-issue this call if it is still needed." ), @@ -133,6 +142,69 @@ def _deferred_tool_message(call: Any) -> dict[str, Any]: } +def _fallback_summary(executed: list[ToolCallRecord]) -> str: + """Deterministic non-empty answer built from the gathered tool results. + + Used only if the final synthesis call fails or returns nothing, so a turn + never ends on an empty message (BUG-052). + """ + lines: list[str] = [] + for record in executed: + data = record.result + if not isinstance(data, dict): + continue + for item in (data.get("results") or [])[:5]: + if not isinstance(item, dict): + continue + title = item.get("title") or item.get("url") or "" + url = item.get("url") or "" + lines.append(f"- [{title}]({url})" if url else f"- {title}") + if data.get("url") and data.get("text"): + title = data.get("title") or data["url"] + lines.append(f"- [{title}]({data['url']})") + if not lines: + return "Je n'ai pas pu produire de réponse à partir des résultats obtenus." + unique = list(dict.fromkeys(lines)) + return "Voici les sources pertinentes trouvées :\n" + "\n".join(unique) + + +async def _finalize_answer( + llm: Callable[..., Any], + convo: list[dict[str, Any]], + executed: list[ToolCallRecord], + steps: list[dict[str, Any]], + iterations: int, + stopped: str, +) -> AgentResult: + """Guarantee a textual answer when the loop stopped before producing one. + + Web research often exhausts the iteration budget while the model is still + calling tools; returning ``content=""`` left the conversation with steps and + sources but no answer. One final tool-less call asks the model to synthesize + the gathered results, and a deterministic source list is used as a last + resort (BUG-052). + """ + content = "" + if executed: + try: + response = await llm( + [*convo, {"role": "user", "content": _FINALIZE_INSTRUCTION}], [] + ) + content = (response.content or "").strip() + except Exception as e: + logger.warning(f"Agent final synthesis failed: {e}") + if not content: + content = _fallback_summary(executed) + return AgentResult( + content=content, + messages=convo, + tool_calls=executed, + steps=steps, + iterations=iterations, + stopped=stopped, + ) + + def _execute_confirmed( ctx: ToolContext, confirm_pending: dict[str, Any], @@ -275,13 +347,14 @@ async def run_agent( for index, call in enumerate(response.tool_calls): if quota is not None and len(executed) >= quota: logger.warning(f"Agent reached the tool-call quota ({quota})") - return AgentResult( - content=response.content or "", - messages=convo, - tool_calls=executed, - steps=steps, - iterations=iteration, - stopped=STOP_QUOTA_EXCEEDED, + # Keep the conversation valid for the synthesis call: the + # assistant message announced every tool call of the batch. + for skipped in response.tool_calls[index:]: + convo.append(_deferred_tool_message( + skipped, "Not executed: the tool-call quota was reached." + )) + return await _finalize_answer( + llm, convo, executed, steps, iteration, STOP_QUOTA_EXCEEDED ) try: result = call_tool(call.name, ctx, call.arguments) @@ -327,11 +400,6 @@ async def run_agent( }) logger.warning(f"Agent reached max iterations ({max_iterations})") - return AgentResult( - content="", - messages=convo, - tool_calls=executed, - steps=steps, - iterations=max_iterations, - stopped=STOP_MAX_ITERATIONS, + return await _finalize_answer( + llm, convo, executed, steps, max_iterations, STOP_MAX_ITERATIONS ) diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index cc5b9bc..9537364 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.7.2" +version = "2.7.3" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index dc9fe5a..85e4ab5 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.7.2" +version = "2.7.3" description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian" authors = ["Bruno Charest"] edition = "2021" diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index ba82120..c2df39a 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json", "productName": "ObsiGate", - "version": "2.7.2", + "version": "2.7.3", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/ISSUES_TODOLIST.md b/docs/ISSUES_TODOLIST.md index 1c3f220..348933f 100644 --- a/docs/ISSUES_TODOLIST.md +++ b/docs/ISSUES_TODOLIST.md @@ -161,6 +161,7 @@ Avant de corriger quoi que ce soit, un agent IA doit : | *BUG-049* | [🔵 MINEUR] Assistant IA : icône du bouton « + » invisible (largeur SVG nulle) | 🟢 corrigé | P3 | 📱 frontend | IA | `frontend/style.css` | Ouvrir l'assistant et observer le bouton « + » | Sélecteur porté à `.bookslm-input-area button.bookslm-btn-plus` (la règle générique `padding: 8px 16px` sur un bouton 32 px annulait la largeur de contenu) | Vérifié navigateur : SVG 0 px → 18 px. Journal 2026-09-16 | | *BUG-050* | [🟡 IMPORTANT] Assistant IA : échec de la création d'un sous-dossier contenant un fichier (appels d'outils parallèles + confirmation) | 🟢 corrigé | P1 | ⚙️ backend + 🤖 ia | IA | `backend/agent/loop.py`, `backend/services/mutations.py`, `backend/tools/service.py`, `backend/bookslm.py` | Mode Agent : « crée le dossier X et un fichier Y dedans » puis Appliquer | `backend/agent/loop.py` : résultats « deferred » (`_deferred_tool_message`) pour les `tool_calls` non atteints lors d'une pause de confirmation ; `backend/services/mutations.py` : `create_directory(..., exist_ok=True)` ; `backend/tools/service.py` + `backend/bookslm.py` : consignes `create_file` (parents auto-créés, chemin imbriqué unique) | Cause : le message assistant listait plusieurs `tool_calls` mais la pause n'ajoutait le résultat que du seul appel confirmé → conversation invalide (tool_call_id sans réponse) au resume. Tests : `tests/test_agent_loop.py` (+1), `tests/test_tools_mutations.py` (+1), `tests/test_api_main.py` (+1) | | *BUG-051* | [🟡 IMPORTANT] Assistant IA : la recherche web répond toujours « je ne peux pas accéder à internet » (mode agent ou non) | 🟢 corrigé | P1 | ⚙️ backend + 🤖 ia | IA | `backend/tools/web.py`, `tests/test_web_tools.py` | Assistant : « fais une recherche sur l'horaire du Canadien de Montréal 2026-2027 » | `backend/tools/web.py` : chaîne de repli sans clé — SearXNG puis DuckDuckGo (HTML sans JS) puis Bing (HTML), premier fournisseur non vide retenu (`provider`), replis désactivables via `OBSIGATE_WEB_FALLBACK=0` | Cause : l'instance SearXNG par défaut (`search.dracodev.net`) remonte 0 résultat (moteurs amont suspendus/CAPTCHA) → le modèle en déduisait une absence d'accès réseau. Tests : `tests/test_web_tools.py` (+4) | +| *BUG-052* | [🟡 IMPORTANT] Assistant IA : recherche web sans réponse finale (10 étapes + sources affichées, aucun texte dans la conversation) | 🟢 corrigé | P1 | ⚙️ backend + 🤖 ia | IA | `backend/agent/loop.py`, `tests/test_agent_loop.py` | Assistant (mode agent) : recherche web qui enchaîne 10 étapes puis n'affiche aucune réponse | `backend/agent/loop.py` : `_finalize_answer` — dernier appel LLM sans outil (instruction de synthèse) quand le budget d'itérations/quota est épuisé, repli déterministe `_fallback_summary` (liste des sources), résultats `deferred` pour les appels non atteints du lot en quota | Cause : `content=""` renvoyé sur `STOP_MAX_ITERATIONS`/`STOP_QUOTA_EXCEEDED` alors que le modèle appelait encore des outils. Tests : `tests/test_agent_loop.py` (+2) | | | | | | | | | | | | | ### TODOs techniques (améliorations / nouvelles tâches) @@ -213,6 +214,7 @@ Avant de corriger quoi que ce soit, un agent IA doit : | 2026-09-16 | BUG-050 | Correction | `backend/agent/loop.py`, `backend/services/mutations.py`, `backend/tools/service.py`, `backend/bookslm.py`, `tests/test_agent_loop.py`, `tests/test_tools_mutations.py`, `tests/test_api_main.py`, `docs/features/ai-tools-mcp.md`, `CHANGELOG.md` | **BUG-050** : création d'un sous-dossier contenant un fichier en mode Agent. (1) La boucle d'agent renvoyait la conversation sans réponse pour les `tool_calls` non atteints lorsqu'un appel mutateur déclenchait une confirmation → le provider rejetait le tour de reprise (« tool_call_id » orphelin) ; les appels restants reçoivent désormais un résultat `deferred` explicite (`_deferred_tool_message`) que le modèle réémet après confirmation. (2) `create_directory` est idempotent côté outil IA (`exist_ok=True`, succès si le dossier existe), le REST restant strict (409). (3) Consignes renforcées : `create_file` crée les dossiers parents, un seul appel avec chemin imbriqué suffit (`backend/bookslm.py`, descriptions d'outils). Vérifié : pytest 1091 passed / 6 skipped, ruff 0 (backend), mypy 0 (71 fichiers), tests frontend validate-imports 38 modules + unit 9/9. | 🟢 corrigé (en attente vérif utilisateur) | | 2026-09-16 | BUG-051 | Correction | `backend/tools/web.py`, `tests/test_web_tools.py`, `docs/features/ai-tools-roadmap.md`, `docs/features/ai-assistant-conversation-ux.md`, `docs/ROADMAP.md`, `CHANGELOG.md` | **BUG-051** : `web_search` ne dépend plus d'une seule instance SearXNG. Nouvelle chaîne de fournisseurs (`_provider_chain`) : SearXNG (auto-hébergé, JSON) → DuckDuckGo (`html.duckduckgo.com/html/`, extraction `result__a`/`result__snippet`, décodage du lien `uddg=`) → Bing (`www.bing.com/search`, extraction `h2 > a` + `p.b_lineclamp*`, décodage de la redirection `u=a1`), UA navigateur, premier fournisseur non vide retenu et exposé (`provider`). Le champ `warning` final liste les fournisseurs essayés ; replis désactivables via `OBSIGATE_WEB_FALLBACK=0` ; erreur `web_search_unavailable` uniquement si tous les fournisseurs sont injoignables. Vérifié : pytest 1082 passed / 6 skipped (14 erreurs MCP préexistantes, sans lien), ruff 0 (backend), mypy 0 (`backend/tools/web.py`), `tests/test_web_tools.py` 13/13, recherche live Bing (horaire Canadiens) sur l'hôte. | 🟢 corrigé (en attente vérif utilisateur) | | 2026-09-16 | BUG-051 (complément) | Correction | `backend/tools/web.py`, `CHANGELOG.md` | **BUG-051** suite : un `User-Agent` navigateur seul ne suffit pas — Bing renvoie une SERP factice (résultats sans rapport, ex. « highest paying jobs » / « Sam Reid ») aux requêtes sans en-têtes de navigation. Ajout de `BROWSER_HEADERS` (`Accept-Language`, `Sec-Fetch-*`, `Upgrade-Insecure-Requests`) pour DuckDuckGo et Bing. Vérifié **en conteneur** (`obsigate-test`, v2.7.1) : `web_search('Canadien de Montreal horaire matchs 2026 2027')` → `provider: bing`, 5 résultats pertinents (nhl.com/fr/canadiens, rds.ca, fr.wikipedia.org). | 🟢 corrigé (en attente vérif utilisateur) | +| 2026-09-16 | BUG-052 | Correction | `backend/agent/loop.py`, `tests/test_agent_loop.py`, `CHANGELOG.md` | **BUG-052** : la boucle d'agent ne rendait plus jamais de réponse vide. `_finalize_answer` : à l'épuisement du budget d'itérations (`STOP_MAX_ITERATIONS`) ou du quota d'appels (`STOP_QUOTA_EXCEEDED`), un dernier appel LLM **sans outil** reçoit une instruction de synthèse (« N'appelle plus aucun outil. Réponds maintenant… ») et son texte devient la réponse ; si l'appel échoue ou reste vide, `_fallback_summary` compose une liste déterministe des sources (`web_search`/`fetch_url`) pour ne jamais renvoyer un tour vide. Les `tool_calls` non atteints lors d'un arrêt sur quota reçoivent un résultat `deferred` (conversation valide pour la synthèse). Vérifié : `tests/test_agent_loop.py` 16/16 (+2 : synthèse finale, repli sources), suite complète 1084 passed / 6 skipped (14 erreurs MCP préexistantes), ruff 0 (backend), mypy 0 (`backend/agent/loop.py`). | 🟢 corrigé (en attente vérif utilisateur) | --- diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 8bd0519..987736d 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.7.2 | **Dernière mise à jour :** 2026-09-16 +> **Version :** 2.7.3 | **Dernière mise à jour :** 2026-09-16 > **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact > vers les fonctionnalités livrées. > - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)** diff --git a/docs/features/ai-tools-roadmap.md b/docs/features/ai-tools-roadmap.md index 1f9f8f4..33eb4a8 100644 --- a/docs/features/ai-tools-roadmap.md +++ b/docs/features/ai-tools-roadmap.md @@ -80,6 +80,11 @@ audit) et **jamais** avec un token en dur : - Livré : la note intermédiaire du modèle devient une étape visible. - Extension possible : exposer les itérations de la boucle (`iterations`) comme étapes de planification quand un outil de plan est ajouté. +- **Garantie de réponse finale — ✅ livré (BUG-052)** : à l'épuisement du budget + d'itérations ou du quota d'appels d'outils, `_finalize_answer` déclenche un + dernier appel LLM **sans outil** (instruction de synthèse) ; un repli + déterministe liste les sources si cet appel échoue. Une recherche web ne peut + plus se terminer sur une conversation sans texte. ## 4. Transverse — à faire avec la phase 2 diff --git a/package.json b/package.json index e4b39b0..7db1344 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsigate", - "version": "2.7.2", + "version": "2.7.3", "description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.", "main": "patch.js", "directories": { diff --git a/tests/test_agent_loop.py b/tests/test_agent_loop.py index ece9a94..ca4ab9a 100644 --- a/tests/test_agent_loop.py +++ b/tests/test_agent_loop.py @@ -182,6 +182,40 @@ class TestConfirmationAndLimits: assert result.iterations == 2 assert len(result.tool_calls) == 2 + @pytest.mark.asyncio + async def test_max_iterations_synthesizes_final_answer(self, monkeypatch): + """BUG-052: exhausting the budget must still produce an answer.""" + _register(monkeypatch, "_echo", lambda ctx, params: {"value": 1}) + llm = ScriptedLLM([ + LLMResponse(tool_calls=[ToolCall(id="1", name="_echo", arguments={})]), + LLMResponse(tool_calls=[ToolCall(id="2", name="_echo", arguments={})]), + LLMResponse(content="synthèse finale"), + ]) + result = await run_agent( + [{"role": "user", "content": "loop"}], ctx=_ctx(), llm=llm, max_iterations=2 + ) + assert result.stopped == STOP_MAX_ITERATIONS + assert result.content == "synthèse finale" + # The last call is tool-less and carries the synthesis instruction. + assert llm.calls[-1]["tools"] == [] + assert "N'appelle plus aucun outil" in llm.calls[-1]["messages"][-1]["content"] + + @pytest.mark.asyncio + async def test_max_iterations_falls_back_to_sources(self, monkeypatch): + """An empty/failed synthesis still returns the gathered sources.""" + _register(monkeypatch, "_search", lambda ctx, params: { + "results": [{"title": "T", "url": "https://ex.dev/a"}] + }) + llm = ScriptedLLM([ + LLMResponse(tool_calls=[ToolCall(id="1", name="_search", arguments={})]), + LLMResponse(content=""), + ]) + result = await run_agent( + [{"role": "user", "content": "loop"}], ctx=_ctx(), llm=llm, max_iterations=1 + ) + assert result.stopped == STOP_MAX_ITERATIONS + assert "https://ex.dev/a" in result.content + # ═══════════════════════════════════════════════════════════════════ # Permissions (index-backed)