Saltar a contenido

ADR-072: Asistente y MCP guiados: wizard, flujos, informes y acciones masivas

Estado: Aceptado (2026-11-10, con las decisiones del dueño del producto de la sección «Decisiones del dueño»). Sin implementar. Fecha: 2026-11-10 Autores: Giampiero (mantenedor principal) Relacionados: ADR-019 (frontera del MCP), ADR-037 (bandera por entidad), ADR-053 (catálogo convergente, taxonomía VER·2FA·…·MAS·SEN), ADR-065 (nada se borra), ADR-067 (peldaños 0–4, propuesta por referencia, resultado único), ADR-070 (privacidad por función), ADR-006 (residencia)


Contexto

El dueño del producto pidió (2026-11-10) que el asistente y el MCP pasen de «catálogo que responde» a «herramienta de trabajo diario»:

  1. Dar los pasos de cada tarea de la plataforma como un wizard («cómo se hace»).
  2. Indicar los flujos de trabajo y ejecutarlos con el MCP cuando hay datos, preguntando los que falten.
  3. Tablas y gráficos descargables sobre la información disponible de la dependencia.
  4. Las acciones rutinarias del perfil y búsquedas que devuelven enlaces para seguir en la app.
  5. Un MCP cada vez más completo, incluidas las acciones masivas.

Y una restricción: el asistente no se apaga a la fuerza; es una herramienta del usuario, como la radicación.

Lo que ya existe (leído del código, no supuesto)

  • Catálogo (services/mcp-server/app/tools/catalog.py, CATALOG): una Tool por endpoint del gateway; Param es la fuente única de argumentos (required, requerido_si, enum, items_schema, resumen); Tool.enlace ya existe, pero como cadena relativa escrita a mano (/expedientes/{expediente_id}, /admin/trd…) y solo alimenta la tarjeta de /aprobar.
  • Procedimientos (tools/procedures.py, services/procedure_runner.py): orden fijo, una propuesta para todo el trámite, cada paso reautorizado con el token de la persona, pre-vuelo de permisos de todos los pasos y sin atomicidad fingida.
  • Escalera (services/write_gate.py, catalog.peldano_de): 0 lectura · 1 aprobar · 2 CONFIRMO · 3 teclear un dato del servidor o N (confirmar_conteo) · 4 sesión interactiva. NO_OFRECIDAS (services/scope.py) declara lo que no se ofrece con código de la taxonomía; EXCLUIDAS_A_PROPOSITO (scripts/censo_mcp.py) es su espejo por ruta.
  • Guía (services/guia.py, orfeo://guia) e índice (services/indice.py: mi_alcance, guia_flujo, buscar_herramientas, PARA_EMPEZAR por flujo): se calculan del catálogo filtrado por permiso; dicen qué herramienta usar, no qué pantalla abrir ni en qué orden se hace una tarea en la app.
  • Flujos: tools/flujos.py clasifica las rutas en F1–F13 (no hay F14).
  • Asistente (services/assistant.py): bucle manual con Claude, as-the-user, 403/404 → no_encontrado, recorte por residencia (nivel_seguridad >= 2) y por privacidad (privacidad.recortar_por_privacidad), verificación posterior de números de radicado (no_verificados). La respuesta es Markdown; el BFF la sanea (frontend/src/lib/server/sanitizeHtml.js), que admite enlaces https:// absolutos.
  • Masivas existentes: radicar_en_lote y crear_expedientes_en_lote (peldaño 3 por N, hasta 20 por MCP; los backends admiten 1.000 y 100), reprocesar_lote_radicacion, conciliar_terceros_en_lote, POST /expedientes/{id}/radicados/batch y la reasignación en cascada (USUA_PERM_ADMIN 3). No hay acto masivo de trámite (asignar, trasladar, informar, finalizar varios radicados): workflow-service solo tiene las rutas unitarias.
  • Informes: GET /reports/radicados (+ .csv con _csv_safe), con SGD_PERM_ESTADISTICA; frontend/src/lib/utils/csvExport.js neutraliza fórmulas en cliente; /reportes dibuja barras con CSS, sin librería de gráficos.
  • Bandera (ADR-037): public.tenants.asistente_habilitado gobierna las rutas /api/v1/assistant/* y las tools E21; el MCP genérico no se apaga. Encender la bandera es la autorización de la transferencia internacional de datos de nivel 1 (Ley 1581 art. 26).

Decisión

D1. Una sola regla para los seis frentes: el modelo propone y describe; el servidor resuelve; el navegador materializa

Todo lo nuevo se declara en el mcp-server (sin BD, sin almacén, sin Redis salvo propuestas), se calcula a partir del catálogo con la identidad de /auth/me y se despacha por el gateway con el token de la persona. Lo que sale al proveedor del modelo es lo mínimo para conversar; filas, contenido y archivos los pide el navegador con la sesión de la persona. Se aplica en cada frente:

Frente Lo que ve el modelo Lo que resuelve el servidor Lo que materializa el navegador
Wizard la guía (texto declarado) qué guías ve la persona el paso a paso con sus enlaces
Flujos los campos que faltan validación, opciones, propuesta la tarjeta y la confirmación
Informes el resumen agregado descriptor validado contra un catálogo tabla, gráfico y descarga
Enlaces la clave de ruta la URL, desde una lectura con 200 la navegación
Masivas la vista previa (conteos) dry-run, lote, resultados por ítem selección y confirmación por N

D2. (a) El wizard vive en el servidor como guías declarativas, y funciona sin el modelo

  • Dónde: app/tools/guias.py, GUIAS: tuple[Guia, ...]. Una Guia = una tarea de la plataforma («radicar una entrada», «trasladar un radicado», «cerrar y archivar un expediente», «prestar una unidad física»…) con flujo (F1–F13), permiso/min_crud (los de la herramienta o procedimiento que la ejecuta, no unos propios), ejecuta (nombre de Tool o Procedimiento) y pasos: tuple[PasoGuia, ...]. Cada PasoGuia lleva titulo, explicacion (texto de la pantalla, escrito a mano), pantalla (clave del registro de rutas, D5) y campos (nombres de Param de la herramienta: etiqueta, tipo, obligatoriedad y opciones salen del Param, no se repiten).
  • Filtrado: el mismo puede_usar que las tools. Una guía cuya herramienta la persona no puede usar no se muestra como pasos: sale en «no puede» con el fallback de la herramienta (la vía alternativa, como hoy en orfeo://guia).
  • Superficie: (1) REST GET /api/v1/mcp/guias y GET /api/v1/mcp/guias/{tarea}, fuera de /api/v1/assistant/* y sin exigir_asistente: es documentación calculada, no conversación, y no sale al proveedor; (2) recurso MCP orfeo://guia/{tarea} y tool de lectura guia_de_tarea para clientes externos (Hermes la recibe como datos: pasos, pantalla y, al final, la herramienta que lo hace por él); (3) el asistente la cita en vez de redactar los pasos de memoria (prompt: «si existe guía, úsala»).
  • Frontend: panel «¿Cómo se hace?» del AssistantDock, visible aunque la bandera esté apagada (consume /api/v1/mcp/guias por un BFF propio). Muestra los pasos con Stepper, un botón «Ir a la pantalla» por paso y, si la bandera está encendida, «Hacerlo con el asistente» (abre D3 con la tarea elegida).
  • Por qué así: es lo que permite cumplir «no se apaga» sin tocar la autorización de la Ley 1581: la parte guiada no transfiere nada y no depende del proveedor. Que los pasos de pantalla estén escritos a mano es el riesgo de deriva; lo cubre un guardián (todo pantalla existe en el registro y en el árbol de rutas; todo campo es un Param de ejecuta; toda ejecuta existe; toda tool de escritura del núcleo PARA_EMPEZAR tiene guía).

D3. (b) Ejecutar un flujo con lo que hay y preguntar lo que falta: completitud calculada por el servidor, sin estado y sin escribir

  • Tool de lectura nueva preparar_tramite(tarea, datos) (peldaño 0): corre coerce_args en modo diagnóstico y devuelve {listo, resueltos, faltan[], invalidos[]}. Cada elemento de faltan trae nombre, etiqueta, pregunta (nuevo Param.pregunta, texto para la persona) y, cuando el valor es una referencia, opciones_desde (nuevo Param.opciones_desde: la lectura del catálogo que lista los valores válidos, por ejemplo listar_dependencias para dest_dept_code, listar_tipos_documentales para doc_class). Incluye los requerido_si y exactly_one_of ya declarados. No guarda nada: el estado de la conversación lo lleva el cliente (el asistente en su historial; Hermes en el suyo).
  • Regla para el modelo: las referencias (UUID, códigos) se resuelven con opciones_desde, nunca se teclean ni se infieren; lo que no sale de una lectura se le pregunta a la persona. El asistente no propone hasta que listo=true.
  • Elicitación en /api/v1/mcp/sesion: si el cliente la admite, el servidor puede pedir los faltan por formulario. Pedir datos no es aprobar: el acto sigue pasando por la propuesta y su peldaño. La regla vigente se mantiene: en peldaño 4 no se aprueba por elicitación.
  • Lo que NO cambia: invocar sigue sin ejecutar; la tarjeta la redacta el catálogo y las lecturas de tarjeta (Lectura) muestran los nombres que resuelve el servidor (dependencia, serie, tipo), no lo que dijo el modelo. Ningún procedimiento aprueba lo que redacta (se mantiene el corte de proyectar_respuesta).
  • Alternativa descartada: un «borrador de trámite» con estado en Redis. Duplicaría la propuesta (que ya es el estado firmado del acto), exigiría TTL y limpieza, y abriría un segundo sitio donde viven argumentos sin firmar.

D4. (c) Informes: descriptor validado + datos por el BFF + render y exportación en el navegador; al modelo solo el resumen

  • Catálogo de informes app/tools/informes.py, INFORMES: cada Informe = id, endpoint (GET agregado de un servicio de dominio), permiso/min_crud del endpoint, filtros (Param), dimensiones admitidas (cerradas: dependencia, tipo documental, estado, mes, medio de conservación, serie TRD; nunca remitente, asunto, destinatario ni texto libre), medidas (conteo, vencidos, días promedio) y graficos admitidos (barras, líneas por mes, tabla). El modelo no arma URLs: elige id + filtros + dimensión, y el servidor valida contra el catálogo (sin esto, un descriptor libre sería un SSRF con el token de la persona).
  • Tool de lectura preparar_informe(informe, filtros, dimension, grafico): el mcp-server llama al endpoint con el token de la persona, devuelve al modelo solo resumen (total, número de grupos y los 5 mayores por una dimensión admitida, sin nivel_seguridad como dimensión) y a la persona un descriptor ({informe, filtros, dimension, grafico}) más el enlace a /reportes?informe=…. El resumen no lleva filas.
  • Materialización: el AssistantDock pinta una InformeCard a partir del descriptor; su BFF (/asistente/api/informe) revalida el descriptor contra el mismo catálogo (servido por GET /api/v1/mcp/informes) y pide el endpoint con locals.accessToken. Tabla con DataTable; gráfico SVG en línea generado por un componente Svelte (etiquetas como nodos de texto, nunca {@html}); descarga CSV con escapeCsvFormulaInjection y SVG/PNG del gráfico. XLSX queda fuera de este ADR (exigiría una dependencia; el CSV abre en cualquier hoja).
  • Alcance de los datos: el de la persona, sin ampliación. Los endpoints agregados cuentan con la misma condición que sus listas: clearance (no-read-up) y el predicado de privacidad de la vista (ADR-070) en el WHERE, también en el total (como ADR-071 D4). «Mi dependencia» es la dependencia activa de /auth/me, nunca un argumento del modelo. Dos familias: operativos de la dependencia (bandeja por estado, vencimientos, carga por persona), con el permiso de lectura de trámite, en un endpoint nuevo de workflow-service; institucionales (radicación por tipo y mes), los existentes con SGD_PERM_ESTADISTICA.
  • Qué no sale al proveedor (SEN): filas, asuntos, remitentes, nombres de personas en «carga por persona» (al modelo va el conteo por persona como persona_1…n; los nombres solo en el navegador) y cualquier dimensión nivel_seguridad o privacidad. Un informe cuyo resumen no pueda cumplir esto no se ofrece al modelo: queda solo en la app.
  • Asiento: el endpoint de dominio asienta report.consultado (quién, informe, filtros, número de filas, canal) atómico con la lectura. La descarga ocurre en el navegador sobre esos mismos datos, así que no hay una segunda lectura que asentar.
  • Alternativa descartada: un servicio que renderice y guarde los archivos (en el almacén, con enlace temporal). Crea copias derivadas que sobreviven a una reclasificación o a una inactivación (contra ADR-065 D5 y el no-read-up), exige borrarlas (contra ADR-065) y abre un egreso nuevo. El CSV por servidor que ya existe (radicados.csv) se mantiene para la app y sigue excluido del MCP (BIN).

D5. (d) Enlaces: registro único de rutas de la app, y un enlace solo sale de una lectura que respondió 200

  • Registro app/tools/rutas_app.py, RUTAS: dict[str, Ruta] (radicado → /bandeja?radicado={id}, expediente → /expedientes/{id}, busqueda → /busqueda?q={q}, prestamo, transferencia, firma, borrador, informe…). El dueño es el mcp-server, que es quien produce los enlaces; el frontend conserva rutasAdmin.js para lo suyo. Tool.enlace pasa de cadena libre a clave del registro (enlace=RUTA("expediente", id="expediente_id")).
  • Guardián cruzado (shared/tests/test_rutas_app_existen.py): toda plantilla del registro existe en frontend/src/routes/(app)/** y sus parámetros de consulta están en la lista que lee la página (mismo estilo que pinar-paridad.test.js).
  • No-oráculo: el enlace se construye en resultado.de_respuesta solo sobre un objeto devuelto con 200 por una lectura hecha con el token de la persona, y con el id de esa respuesta, nunca con un argumento del modelo. Con 403/404 no hay enlace (sigue no_encontrado). Un enlace no lleva contenido (asunto, nombres) en la consulta: solo identificadores y los filtros que dio la persona. Abrirlo es otra lectura con la sesión de quien hace clic: otra persona recibe el «no encontrado» de la app.
  • Transporte: el resultado lleva enlaces: [{clave, url, etiqueta}] (relativos). El asistente los entrega aparte del texto (AssistantMessageResponse.enlaces), y el BFF elimina del Markdown del asistente todo enlace absoluto que no sea del propio origen (el saneador hoy admite https://, y es el canal clásico de exfiltración por inyección). Para un cliente externo, el url relativo se completa con la URL pública de la app que declara la entidad.

D6. (e) Acciones masivas: endpoint masivo en el servicio de dominio, con vista previa atada, lote con asiento y resultado por ítem

  • Dónde: en el servicio dueño del acto (regla del mcp-server: «si falta un endpoint, se construye en su dominio»). Primer servicio: workflow-service, POST /api/v1/workflows/masivo/{accion} con accion ∈ {asignar, trasladar, informar}. Lo usan la app (selección múltiple en la bandeja) y el MCP: una sola puerta.
  • Contrato:
  • Cuerpo: {items: [radicado_id], parametros: {...los del acto unitario}, vista_previa: bool, huella?: str}, más la cabecera Idempotency-Key (el jti de la propuesta, que el gate ya manda).
  • Vista previa (vista_previa=true): no escribe nada. Evalúa cada ítem con las mismas reglas del acto unitario (permiso, estado, clearance y privacidad en el WHERE) y devuelve {por_item: [{id, resultado: aplicable|no_aplicable|no_encontrado, motivo}], conteos, huella}. La huella es el SHA-256 de (acción, parámetros, ids aplicables y versión de cada ítem).
  • Ejecución: exige huella. Si al ejecutar la selección aplicable ya no coincide, responde 409 seleccion_cambiada y no ejecuta (mismo criterio que archivo_cambiado del visor). Cada ítem va en su propia transacción con su asiento (workflow.trasladado …, con correlacion.lote_id): sin atomicidad fingida, y el resultado por ítem es hecho | rechazado | no_encontrado.
  • Lote: tabla de tenant operaciones_masivas (+ operaciones_masivas_items), con clave_idempotencia UNIQUE. Asiento de lote workflow.masivo_ejecutado (quién, acción, N pedidos/hechos/rechazados, canal) atómico con la fila del lote. Reintentar con la misma clave devuelve el lote guardado y no vuelve a ejecutar. POST /masivo/{lote_id}/reprocesar repite solo los rechazados por causa transitoria. Nada se borra (ADR-065): el lote y sus ítems son registro.
  • No-oráculo: un ítem invisible para la persona es no_encontrado, igual que uno inexistente, en la vista previa, en la ejecución y en los conteos. No se reporta «omitidos por no visibles» (lo que sí hace la cascada de administración).
  • Topes: 20 por MCP (como los lotes vigentes), 100 por la app; cuota por persona y hora en el gateway.
  • Peldaño: el de la masiva = máx(peldaño del acto unitario, 3), con confirmar_conteo sobre los aplicables de la vista previa (se teclea N) y Param.resumen para que la tarjeta los enumere (número de radicado, no asunto). En este ADR solo se ofrecen en masa actos cuyo peldaño unitario es ≤ 2. El peldaño 4 no se ofrece en masa.
  • No se ofrecen en masa (nuevas entradas en NO_OFRECIDAS/EXCLUIDAS_A_PROPOSITO con código MAS, o con el del acto): anular, inactivar o reactivar (ADR-065), reclasificar, desclasificar o cambiar privacidad (VER: hay que ver cada documento), firmar o dar visto bueno (2FA/VER), aprobar borradores (VER), retipificar (mueve retención TRD), fijar o levantar retención legal, disposición final fuera de su lote de eliminación, devolver (el motivo es por radicado) y cualquier acto FRO o REG. La reasignación en cascada sigue como está (administración, peldaño 4).

D7. (f) Rutinas por perfil: se derivan de los permisos efectivos, no de un nombre de rol

  • Los grupos son configurables por entidad y «el rol» no existe como dato estable; lo que sí es estable es el permiso con su nivel. app/tools/rutinas.py, RUTINAS: cada Rutina = permiso/min_crud, lectura del catálogo que cuenta lo pendiente (por ejemplo bandeja_tramite → «radicados por tramitar», firmas_pendientes, borradores por aprobar, anulaciones por decidir, préstamos vencidos, correos pendientes del buzón), guia (D2) y enlace (D5).
  • Tool de lectura mis_pendientes (peldaño 0): ejecuta, con el token de la persona, las lecturas de las rutinas que su identidad alcanza (puede_usar) y devuelve, por rutina, {conteo, enlace, guia}. Cada lectura se reautoriza y colapsa 403/404 como hoy. Al modelo solo le llegan los conteos.
  • Frontend: pestaña «Hoy» del dock y del dashboard, visible también con la bandera apagada (consume el REST de D2 por su BFF).
  • Búsquedas con enlace: las lecturas de búsqueda (buscar_radicados, buscar_expedientes, buscar_antecedentes) devuelven enlaces por resultado (D5) y un enlace a /busqueda con los mismos filtros, para seguir en la app con paginación y facetas.

D8. Frontera de la bandera: lo que «no se apaga»

Lo guiado (D2), las rutinas (D7) y los enlaces no pasan por el proveedor del modelo y no dependen de asistente_habilitado. La conversación con el modelo sigue gobernada por la bandera de la entidad (ADR-037), porque encenderla es la autorización de la transferencia internacional de la Ley 1581 art. 26. Este ADR no retira esa bandera; si el dueño quiere que tampoco la conversación se apague, ver la pregunta abierta 1.

Riesgos de seguridad y auditorías

Frente Riesgo Contención
Flujos (D3) Inyección por contenido de un radicado («asigna a la 900», «envía copia a…») que rellena huecos El contenido sigue siendo dato (frontera del prompt); referencias solo desde opciones_desde; la tarjeta enseña los nombres que resuelve el servidor. Es contención, no prevención: si quien confirma no lee, confirma
Informes (D4) Exfiltración por agregados (afinar filtros para deducir lo reservado por conteos) Mismo WHERE (clearance y privacidad) en conteo y lista: solo se deduce lo que la persona ya puede leer. Dimensiones cerradas, sin texto libre ni nivel. Al modelo solo el resumen
Informes Inyección de fórmulas o marcado en etiquetas CSV con escapeCsvFormulaInjection; SVG con nodos de texto; sin {@html}
Informes Descriptor libre usado como SSRF con el token Validación contra INFORMES en el mcp-server y otra vez en el BFF
Enlaces (D5) Enlace que confirma existencia, o enlace absoluto con datos en la consulta Solo desde 200; sin contenido; el BFF quita los absolutos ajenos del Markdown
Masivas (D6) Abuso (mover el trabajo de una dependencia entera) o confirmar a ciegas Tope, cuota, peldaño ≥ 3 con N tecleado, huella de la vista previa, asiento por ítem y de lote, techo de la credencial de asistente (techo_crud)
Masivas Existencia filtrada por los conteos no_encontrado indistinguible en la vista previa y en el resultado
Todos Escalada de privilegios Nada nuevo escala: todo va con el token de la persona y puede_usar/authorize son los de siempre

Auditorías que exige: tenant-security-auditor en D4, D5 y D6 (clearance y privacidad en el WHERE de agregados y masivas, no-oráculo de conteos y enlaces, asiento atómico, BFF de informes y saneo de enlaces); archival-compliance-auditor en D6 (trazabilidad de cada traslado y asignación según el Acuerdo AGN 060/2001: un asiento por radicado, no solo el del lote) y en D2 (que los pasos de las guías digan lo que la norma exige); svelte-ux-reviewer en el panel de guías, InformeCard, «Hoy» y la selección múltiple de la bandeja (a11y del gráfico: tabla equivalente y aria en el SVG).

Incrementos

Orden: cada uno se apoya en el anterior; el 1 es la base de enlaces que usan todos.

# Alcance Servicios Criterio de aceptación verificable
1. Registro de rutas y enlaces rutas_app.py; Tool.enlace como clave; enlaces[] en resultado solo desde 200; AssistantMessageResponse.enlaces; el BFF quita del Markdown los absolutos ajenos; render de enlaces en el dock mcp-server, frontend Guardián cruzado verde (toda ruta existe en el árbol); test: una lectura 404/403 no produce enlaces; test: un enlace usa el id de la respuesta aunque el argumento difiera; test del BFF: [x](https://otro) no sobrevive y [x](/expedientes/…) sí
2. Guías (wizard) guias.py con las tareas de PARA_EMPEZAR (escrituras); REST GET /mcp/guias[/{tarea}] sin bandera; orfeo://guia/{tarea}; tool guia_de_tarea; panel «¿Cómo se hace?» con Stepper mcp-server, frontend Guardián: toda pantalla en el registro, todo campo es Param de ejecuta, toda escritura de PARA_EMPEZAR con guía; con la bandera apagada el panel carga y /assistant/message sigue en 403; una guía sin permiso sale en «no puede» con fallback; censo y cifras de api.md al día
3. Completitud de trámites Param.pregunta, Param.opciones_desde; tool preparar_tramite; prompt del asistente; elicitación de faltantes en /sesion mcp-server preparar_tramite no despacha nada (request.assert_not_called()); casos requerido_si y exactly_one_of reportados en faltan; guardián: todo opciones_desde nombra una lectura existente; todo Param de referencia (UUID o código) de un procedimiento declara opciones_desde
4. Rutinas y búsquedas con enlace rutinas.py; tool mis_pendientes; REST para «Hoy»; enlaces por resultado en las búsquedas mcp-server, frontend Con una identidad sin PERM_FIRMA la rutina de firmas no aparece ni se llama (mock del gateway); un 403 en una lectura deja la rutina en conteo nulo sin motivo; «Hoy» visible con la bandera apagada
5. Informes informes.py; endpoint agregado de dependencia en workflow (clearance y privacidad en el WHERE, dependencia activa, asiento report.consultado); tool preparar_informe; GET /mcp/informes; BFF /asistente/api/informe; InformeCard (tabla, SVG, CSV, PNG) workflow-service, document-service (asiento en los existentes), mcp-server, frontend Test de integración con PG real: el conteo con un usuario de clearance 1 no cuenta lo de nivel 2 ni lo privado ajeno; descriptor con endpoint o dimensión fuera del catálogo → 422 en mcp-server y en el BFF; el resultado al modelo no lleva filas ni nombres (test por forma); CSV con =SUM( sale con '
6. Masivas de trámite Migración de tenant de workflow (operaciones_masivas, _items, clave_idempotencia UNIQUE); POST /workflows/masivo/{asignar,trasladar,informar} con vista previa, huella, asiento por ítem y de lote, reprocesar; tools *_en_masa (peldaño 3, confirmar_conteo); selección múltiple en la bandeja; entradas MAS en NO_OFRECIDAS y en el censo workflow-service, mcp-server, frontend, shared/tests Integración: misma clave dos veces → un solo lote y N asientos, no 2N; huella cambiada → 409 sin escribir; ítem invisible → no_encontrado igual que inexistente (mismo cuerpo); un ítem que falla no deshace los demás; test_toda_operacion_tiene_postura_mcp.py con huecos = 0
7. Masivas de expediente Llevar radicados/batch (incluir en expediente) al mismo contrato (vista previa + huella + lote); tool en masa archive-service, mcp-server Los mismos criterios del 6 sobre archive; sin cambio de gate (USUA_PERM_EXPEDIENTE 2)

Documentación en cada incremento: docs/es/api.md y docs/en/api.md (cifras del catálogo), docs/es/mcp-flujos.md (regenerado por el censo), docs/es/mcp-guia.md, changelogs y roadmap.

Consecuencias, sin suavizar

  • El modelo deja de ser quien «sabe» la plataforma: las guías, las rutinas y los informes se declaran en el servidor y hay que mantenerlos. Una guía con pasos de pantalla desactualizados engaña igual que un modelo que inventa; el guardián solo comprueba que las rutas y los campos existen, no que el texto describa bien la pantalla.
  • Los informes no se pueden pedir enteros por un cliente MCP externo: reciben resumen y enlace. Es deliberado (SEN), y un cliente que quiera filas tiene que abrir la app.
  • La masiva no es atómica y lo dice: un lote puede quedar parcial, y el resultado por ítem es la verdad.
  • El tope de 20 por MCP hace que «trasladar los 300 vencidos» sean 15 confirmaciones; es el costo de que se teclee N con la lista delante.
  • Los enlaces dependen de que el frontend no cambie sus rutas sin tocar el registro; el guardián cruzado lo detecta, pero hace que mover una pantalla sea un cambio en dos sitios.

Alternativas consideradas

  • Wizard redactado por el modelo a partir de orfeo://guia: no funciona sin la bandera, no dice qué pantalla abrir y varía entre turnos. Rechazada.
  • Masivas como bucle de llamadas unitarias en el mcp-server (como un procedimiento con N pasos): no hay asiento de lote (el mcp-server no escribe audit_log), no hay vista previa atada ni idempotencia real, y la app no lo puede usar. Rechazada.
  • Masivas con un solo CONFIRMO (peldaño 2): confirma «un lote», no «estos N». Rechazada por el criterio de ADR-067 D6.
  • Rutinas por nombre de grupo o rol: los grupos son de cada entidad y cambian; el permiso es lo estable. Rechazada.
  • Render de informes en un servicio con almacenamiento temporal: ver D4. Rechazada.

Decisiones del dueño (2026-11-10)

  1. «No se apaga»: la bandera de la entidad se mantiene para la conversación con el modelo (es la autorización de transmitir datos a un proveedor externo, Ley 1581 art. 26). Lo guiado (guías, «Hoy», enlaces) funciona siempre, con o sin bandera.
  2. Masivas por la app: tope de 100 por lote; el lote asíncrono queda para después.
  3. Primer corte de masivas: asignar, trasladar e informar. Finalizar/archivar varios va en un segundo corte.
  4. Informes: en el primer corte, solo conteos por estado, tipo y mes, sin nombres de personas. Los nombres, si se abren después, solo para jefatura y solo en la app, nunca hacia el modelo.
  5. XLSX: no en el primer corte; CSV y gráfico SVG/PNG.

Propuesta de primer incremento

Empezar por el 1 (registro de rutas y enlaces): es pequeño, no toca datos ni gates, cierra una vía de exfiltración que ya existe hoy (enlaces absolutos en el Markdown del asistente) y es la base de los incrementos 2, 4 y 5. El 2 (guías) va justo después, porque es lo más visible para la persona y lo que hace verdad «no se apaga» sin tocar la autorización legal.