feat(umind): repositorio de archivos del espacio y bandeja de aprobación

Dos piezas que se necesitan mutuamente.

ARCHIVOS — a nivel espacio y no de agente, porque los papeles son del negocio:
si mañana crea un segundo agente, sus contratos no se mudan. Subir, descargar,
borrar, cuota por plan, y un botón "usar como conocimiento" que lo manda por la
ingesta que ya existe — un clic, no volver a subir el mismo PDF.

Nunca se sirven por el estático: salen por su endpoint, que valida el tenant.
La cuota se mira antes de escribir, porque rechazar después de copiar 25 MB al
disco es cobrarle el espacio igual.

PENDIENTES — no es un motor de workflows, es una tabla y una regla: lo que sale
hacia afuera pedido por alguien que no es el dueño no se ejecuta, se encola.

El eje es quién está del otro lado, no qué tan peligrosa suena la acción. En el
widget público escribe cualquiera: ahí toda acción espera. En un canal interno
el dueño ya autorizó al escribirlo, y mandarlo a aprobar su propio pedido sería
fricción sin ninguna seguridad a cambio. (El plan decía aprobar siempre el
envío de correo; esto es más flojo a propósito y la propiedad que importa se
mantiene entera: desde un canal público no sale nada sin una persona.)

Cuatro decisiones que valen más que el esquema:
- Se guarda el payload EXACTO y se ejecuta eso. Aprobar no vuelve a llamar al
  modelo: si regenerara, aprobarías una cosa y saldría otra, y la diferencia
  aparecería recién en la mano del cliente. Hay un test que lo vigila.
- Dos personas mirando la misma bandeja pueden aprobar a la vez; el reclamo es
  un UPDATE condicional, así la cotización no sale dos veces.
- Vencen a los 7 días. Una cotización aprobada tres semanas tarde llega con
  precios de otro mes: es peor que ninguna.
- Al cliente nunca se le dice "rechazado" ni se le menciona una aprobación
  interna — se le dice que le responde alguien del equipo. También con test.

El correo de aviso lleva un enlace al panel CON login: un enlace que ejecuta
algo irreversible sin autenticar es un enlace que reenviado por error firma.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-24 21:37:37 -05:00
co-authored by Claude Opus 5
parent b2e2e83fe8
commit 544cba6d34
12 changed files with 733 additions and 1 deletions
+84
View File
@@ -0,0 +1,84 @@
package services
import (
"os"
"strings"
"testing"
)
// La regla de aprobación es lo que separa un asistente de una máquina de spam
// con el dominio del cliente. Si alguien la afloja, esto tiene que fallar.
func TestRequiereAprobacion(t *testing.T) {
casos := []struct {
nombre string
interna bool
tool string
requiere bool
}{
// Canal público: cualquiera escribe. Todo lo que sale, espera.
{"público no puede mandar correo", false, "enviar_correo", true},
{"público no puede llamar una herramienta", false, "avisar_stock", true},
{"público no puede generar documentos", false, "generar_documento", true},
// Leer no saca nada del negocio.
{"público sí puede buscar en el conocimiento", false, "buscar_conocimiento", false},
{"público sí puede leer la bandeja", false, "leer_bandeja", false},
// Canal interno: el dueño ya autorizó al escribirlo. Mandarlo a
// aprobar su propio pedido es fricción sin seguridad.
{"el dueño manda correo directo", true, "enviar_correo", false},
{"el dueño ejecuta herramientas directo", true, "avisar_stock", false},
}
for _, c := range casos {
if got := RequiereAprobacion(c.interna, c.tool); got != c.requiere {
t.Errorf("%s: RequiereAprobacion(interna=%v, %q) = %v, quiero %v",
c.nombre, c.interna, c.tool, got, c.requiere)
}
}
}
// Lo que el modelo le cuenta al cliente cuando algo queda esperando no puede
// filtrar la cocina del negocio.
func TestEncolarNoFiltraElProcesoInterno(t *testing.T) {
b, err := os.ReadFile("umind_aprobacion_service.go")
if err != nil {
t.Fatal(err)
}
s := string(b)
i := strings.Index(s, "func EncolarAccion")
j := strings.Index(s[i:], "\nfunc ")
cuerpo := s[i : i+j]
nota := cuerpo[strings.Index(cuerpo, `"nota":`):]
nota = nota[:strings.Index(nota, "\n")]
for _, prohibido := range []string{"rechaz", "aprobación interna"} {
if strings.Contains(strings.ToLower(nota), prohibido) {
t.Errorf("la nota al cliente menciona %q: %s", prohibido, nota)
}
}
if !strings.Contains(strings.ToLower(nota), "sin mencionar") {
t.Error("la nota tiene que instruir al modelo a no mencionar el proceso interno")
}
}
// El payload se ejecuta tal cual se aprobó: si al aprobar se le volviera a
// preguntar al modelo, se aprobaría una cosa y saldría otra.
func TestAccionAprobadaUsaElPayloadGuardado(t *testing.T) {
b, err := os.ReadFile("umind_aprobacion_service.go")
if err != nil {
t.Fatal(err)
}
s := string(b)
i := strings.Index(s, "func EjecutarAccionAprobada")
j := strings.Index(s[i:], "\n// avisarAccionPendiente")
cuerpo := s[i : i+j]
if !strings.Contains(cuerpo, "accion.ArgumentosJSON") {
t.Error("la ejecución tiene que partir de los argumentos guardados")
}
if strings.Contains(cuerpo, "callAI") || strings.Contains(cuerpo, "ProcessWidgetMessage") {
t.Error("aprobar no puede volver a llamar al modelo: se aprobaría una cosa y saldría otra")
}
// Dos personas mirando la misma bandeja pueden aprobar a la vez.
if !strings.Contains(cuerpo, "ReclamarAccion") {
t.Error("falta el reclamo: sin él, dos aprobaciones simultáneas ejecutan dos veces")
}
}