Hoy publiqué en código abierto la primera versión de mi plataforma de marketing.
Se llama STRAŦUM. Nueve agentes de IA, un espacio de trabajo para agencias con separación de datos por cliente, diez idiomas. Lo construí en 2025, mientras aprendía a escribir software. Después empecé una segunda versión y, en lugar de dejar la primera guardada bajo llave, la regalé.
Si quieres echarle un vistazo antes de leer el resto: github.com/chandlernguyen/stratum-oss
git clone https://github.com/chandlernguyen/stratum-oss
Si diriges equipos de marketing y nunca vas a abrir ese repositorio, salta a "Si nunca vas a leer el código" casi al final. Esa sección es para ti y es corta.
Quiero ser preciso sobre qué es ese repositorio, porque "open source" puede significar muchas cosas, y la mayoría exagera lo que es esto.
Lo que publiqué en realidad
STRAŦUM v1 está publicada y no se desarrolla activamente. El código es público bajo licencia MIT. No hay hoja de ruta, ni calendario de lanzamientos, ni compromiso de soporte — el README lo dice, y la guía de contribución lo repite.
Se publica como implementación de referencia. Es algo para leer, o para hacerle un fork, o del que tomar partes. No es algo de lo que depender. Nadie lo mantiene frente a futuros cambios de dependencias, y el repositorio enumera los avisos de seguridad abiertos que conoce en lugar de fingir que no están ahí.
Lo que no estoy diciendo es que nunca vaya a cambiar. No lo desarrollo más, así que lo más sensato es dar por hecho que se queda como está — pero sí leo lo que me envían, y si alguien reporta un bug o sugiere algo que hace el código más claro, no voy a fingir que no lo vi.
Publicarlo también significaba demostrar que no se iba nada confidencial con él. En el repositorio hay un script que escanea cada archivo rastreado en busca de credenciales, claves privadas, tokens de proveedores e identificadores de producción, y se ejecuta como el primer job del pipeline de CI — antes de las pruebas, porque una clave filtrada queda en el historial en el momento en que se hace push, y borrarla después no deshace eso.
Esto es lo que incluye:
- Nueve agentes — estrategia, persona, contenido, inteligencia de rendimiento, inteligencia competitiva, planificación de campañas, éxito del cliente y dos más. Cada uno es una subclase de un agente base, y todos comparten una estructura de prompt, un registro de herramientas y contexto progresivo.
- Dos esquemas de Postgres. Los datos de los negocios individuales viven en uno, los de las agencias en otro, con Row Level Security como frontera de aislamiento.
- Diez locales, con la locale viajando a la API en una cabecera para que los metadatos generados coincidan con el idioma de la interfaz.
- Un modo demo que no necesita ninguna clave de API. Los agentes devuelven respuestas predefinidas claramente etiquetadas en lugar de llamar a un modelo, así puedes recorrer toda la aplicación sin una cuenta de proveedor.
Si quieres construir tu propia versión de esto
Es el uso que más me gustaría ver, así que vale la pena decirlo claramente en lugar de dejarlo implícito.
Si lo que realmente quieres es tu propio sistema de agentes de marketing — tus propios agentes, tus propios prompts, tu propio esquema, tu propio producto encima — entonces hazle un fork al repositorio y constrúyelo. Para eso está la licencia MIT, y es el uso previsto, no un resquicio. No hay ningún requisito de atribución más allá de conservar el archivo de licencia, y no hace falta consultarme. Toma las partes que sirvan, tira las que no, y cambia todo aquello con lo que no estés de acuerdo.
Prefiero ver una docena de versiones distintas de esto que una sola versión que solo yo haya tocado.
Por qué publicar la v1 en lugar de guardármela para mí
Lo tuve guardado diez meses porque asumí que nadie querría una v1 en la que ya había dejado de trabajar. Esa suposición estaba equivocada, y la razón por la que estaba equivocada vale más que el propio repositorio.
La segunda versión toma otro camino. Lo que estoy construyendo ahora no se parece mucho a esta base de código, y no será un diff contra ella. Mantener la v1 en privado habría significado que poco a poco se convirtiera en una pieza de museo que nadie podría visitar.
La mejor razón es que una aplicación agéntica multi-tenant que funciona es un artefacto más útil que un diagrama de arquitectura. Cuando estaba aprendiendo, lo que ayudaba no era la explicación conceptual — era encontrar un proyecto real y leer cómo lo había organizado alguien más. El razonamiento que se pierde en la explicación es el que más importa.
Escribí cuatro cosas en las notas de arquitectura del repositorio que hice mal y no volvería a hacer, y dejé las cuatro. La primera es un bug de seguridad.
El error que llegó a producción
Una tabla salió sin que Row Level Security estuviera activada.
public.notification_push_deliveries se creó sin la línea que activa RLS, y se otorgó a los roles anónimo y autenticado con todos los privilegios — incluido TRUNCATE — sin ninguna política definida. Está en el esquema que expone la Data API, y la clave anónima se envía dentro del bundle del navegador. Así que durante ese período, cualquiera que tuviera esa clave podía leer la tabla o modificarla. Guarda identificadores de dispositivos para notificaciones push, lo que lo convertía en un problema de exposición de datos, no en algo teórico.
Lo encontré en una revisión de seguridad. No en un reporte de bug, y no en las pruebas, porque la aplicación funcionó perfectamente todo el tiempo. Nada lanza un error cuando a una tabla le falta RLS. Las consultas pasan, la funcionalidad funciona, y el permiso que no debería existir simplemente está ahí, sin hacer nada visible hasta que alguien mira.
Ya está cerrado. RLS está activada en la tabla y sin ninguna política, y a los roles de navegador se les revocaron los permisos, así que la petición que antes habría devuelto filas ahora vuelve como un error de permiso. Lo cuento en pasado a propósito: esto es lo que el código parecía, no lo que parece hoy.
Esa es la parte sobre la que quiero ser preciso: esto no fue una decisión de diseño que acerté. Fue una regresión que cometí y que descubrí después.
Así que dejé de confiar en mi memoria
Lo que hice después es la única pieza de todo esto que le entregaría con confianza a otra persona.
Escribí un test que no nombra ninguna tabla en particular. Recorre el esquema y verifica la regla: toda tabla de un esquema expuesto tiene Row Level Security activada, y ninguna vista materializada con alcance de tenant es legible por un rol de navegador. Si agrego una tabla el mes que viene y me olvido, el test falla, y falla en lo que olvidé, no en una lista que escribí cuando me acordaba.
Dos advertencias honestas, y preferiría que un lector me las reclame en lugar de descubrirlas más tarde:
- La parte de esa verificación dedicada a las vistas materializadas cubre el esquema
publicy no el esquemaagency. El lado de agencias tiene su propia exposición que no he cerrado. - El test se salta a sí mismo cuando no puede alcanzar una base de datos. Un test saltado está en verde, lo que significa que un entorno roto puede esconder un invariante que está fallando.
Si me llevara un solo hábito de todo esto, sería este: cuando encuentro un bug de esta forma, escribo el test que lo habría atrapado — y lo escribo contra la regla en lugar de contra el objeto. Un test que nombra los tres objetos que recordaba solo protege esos tres. Un test que verifica la regla protege el que agregue la semana que viene y me olvide de él.
La segunda trampa
Hay una segunda que salió bien por suerte y no por diseño, y es la que escucho repetida en las conversaciones con proveedores.
Un filtro del lado del cliente no es una frontera. Si tu aplicación filtra filas por organización en el navegador — .eq('org_id', ...) y compañía — entonces el aislamiento se ejecuta en la máquina del usuario, lo que significa que se puede quitar con las herramientas de desarrollo. Todo lo que se puede quitar es una preferencia de visualización, no un control de seguridad. La visibilidad de las filas tiene que aplicarse en la base de datos o detrás de un endpoint de servidor que quien llama no pueda rodear.
Las vistas materializadas son el pariente incómodo de este problema. No pueden tener Row Level Security en absoluto. Un SELECT otorgado sobre una vista materializada devuelve las filas de todos los tenants, y en una migración se ve idéntico al mismo permiso otorgado sobre una tabla, donde las políticas todavía lo limitarían. El repositorio revoca los permisos de los roles de navegador sobre cualquier cosa con alcance de tenant que se materialice, exactamente por esa razón.
El que no he terminado
Row Level Security no puede restringir TRUNCATE. Es un privilegio a nivel de tabla, no a nivel de fila, así que las políticas no se le aplican. Los permisos base del repositorio dan ALL a los roles de navegador en un montón de tablas — lo que incluye TRUNCATE — y nada lo reduce después. Los permisos de las vistas materializadas están revocados. Estos no. Así que hay tablas donde un rol de navegador tiene un privilegio que queda fuera de la frontera que acabo de pasar esta sección describiendo.
En la práctica es latente, no una puerta abierta: la Data API no tiene verbo TRUNCATE, los roles de navegador no pueden conectarse directamente a la base de datos, y un despliegue normal no expone el puerto de la base. Es un problema de higiene. Pero tiene la misma forma que el bug del principio de este post — un privilegio que sobrevive en silencio a su justificación — y prefiero señalarlo que dejar que un lector lo encuentre y se pregunte si yo lo sabía. Hace falta una revocación en los dos esquemas, y un test que verifique que ningún rol de navegador tiene un privilegio que se salte RLS.
Lo que no volvería a hacer
Dos esquemas paralelos duplicaron mucho DDL. Los datos de agencias y de negocios individuales tienen estructuras paralelas en lugar de una sola tabla con una columna discriminadora de tenant. La separación es genuinamente más limpia. La duplicación costó más en mantenimiento de lo que habría costado la columna discriminadora, y la próxima vez tomaría la otra decisión.
El llamado manual de funciones es mucho código. El bucle del agente maneja las llamadas a herramientas a mano en lugar de usar el llamado automático de funciones del proveedor, lo que mantiene el streaming y la ejecución de herramientas bajo control de la aplicación. El costo es que la aplicación tiene que armar ella misma las partes del resultado de la función, y en los modelos actuales esas partes deben llevar el call id además del nombre de la función. Omite el id y no obtienes un error de esquema. Obtienes algo que se lee como si el modelo fuera inestable, y eso es una tarde mucho peor. Si el streaming con herramientas ya está disponible mediante una API de más alto nivel, vale la pena revisar ese intercambio.
La cadena de migraciones llegó a 321 archivos. Una buena parte se llamaba fix_, _v2 y remove_, porque iba agregando correcciones en lugar de editar lo que ya había escrito. El estado real del esquema solo se podía conocer rehaciendo el historial desde el principio. Los posts que escribí en esa época eran más confiados sobre ese período de lo que el código merecía — en noviembre de 2025 dije que treinta y tres migraciones habían "resuelto por fin" el multi-tenant, y después seguí escribiendo migraciones correctivas durante meses.
El frontend confía en las formas de la API sin validarlas. Hay una validación cuidadosa a la entrada — el backend parsea cada petición con Pydantic — y ninguna a la salida. El navegador toma la respuesta y le cree. Un esquema compartido detectaría la deriva cuando un campo cambia de forma, en lugar de dejar que se descubra como una pantalla en blanco. Sumado a los tres anteriores, esas son las cuatro cosas que las notas de arquitectura dicen que haría diferente.
El repositorio publicado reconstruye la cadena de migraciones como veinte migraciones en capas, agrupadas por preocupación — tablas, luego funciones agrupadas por dominio, vistas, índices, triggers, políticas, permisos, tareas programadas y una pasada final de endurecimiento. Eso es cierto a grandes rasgos, no con exactitud, y vale la pena ser honesto sobre dónde falla: una de las veinte es un cajón de sastre admitido para las funciones que no se pudieron clasificar, y la capa de tablas no está dividida como sugieren los nombres de sus archivos. El archivo que lleva el nombre del esquema compartido también crea las ocho tablas de agencia, y el archivo que lleva el nombre del esquema de agencia no contiene ninguna definición de tabla. Las capas son reales; las etiquetas no son perfectas.
Lo que sí acierta la división es la parte que importa para leerlo: el esquema es una pura reorganización, verificada volcándolo antes y después y confirmando que los dos son idénticos bit a bit salvo por el token aleatorio de la herramienta de dump.
Esa verificación es la única razón por la que acepté tocarlo.
Lo que sobrevivió
Row Level Security como la frontera real, en lugar de una funcionalidad que se activa. Una vez que el diseño se organiza alrededor de ella, el aislamiento deja de ser un punto de una checklist y se convierte en una propiedad del sistema.
Las escrituras pasan por funciones de base de datos enrutadas. El código de la aplicación no elige qué esquema tocar. Llama a una función que inspecciona el tipo de organización y despacha, así que la decisión vive en un solo lugar, donde un nuevo camino de código no puede olvidar tomarla.
El registro ignora lo que envía el cliente. El trigger de provisioning no respeta un id de organización ni un rol proporcionados por el cliente, porque esos datos los controla el usuario. Crea una organización nueva y un rol de propietario por defecto. Cosa pequeña, fácil de hacer mal, cara cuando la haces mal.
Construcción perezosa de los clientes. Los servicios construyen su cliente de proveedor en el primer uso y no en el import. Eso suena a preferencia de estilo y no lo es: varios servicios se crean al importar el módulo, así que una construcción inmediata significaba que importar la aplicación requería una clave de API, y el fallo aparecía como un error opaco del SDK del proveedor antes de que nada pudiera arrancar. Hacerlo perezoso es lo que hace posible el modo demo, y eso es lo que permite que un desconocido evalúe el proyecto sin gastar nada.
Esa última es la decisión con la que estoy más contento, y no la tomé por la razón por la que terminó importando. La tomé para detener el crash.
Si nunca vas a leer el código
Esta es la sección que me habría gustado tener cuando estaba del lado de la agencia comprando este tipo de software, así que no hay código en ella.
Lo que vale la pena llevarse es la diferencia entre "filtramos por cuenta" y "la base de datos no puede devolver las filas de otra cuenta".
He comprado plataformas durante la mayor parte de mi carrera y las he construido en los últimos años, y esa distinción es la que no entendí hasta que había enviado la versión equivocada. Una es una regla que el navegador sigue. La otra es una regla que el navegador no puede romper. La mayoría de las herramientas tienen la primera y la describen con el lenguaje de la segunda.
Cuando evalúas una plataforma que va a contener los datos de varios clientes — inteligencia competitiva, datos de rendimiento, definiciones de audiencia, lo que sea — la pregunta que hay que hacer no es "es segura". Todos dicen que sí a eso. La pregunta es: ¿dónde se aplica la separación, y qué pasa si un desarrollador quita el filtro?
Hay dos buenas respuestas. O se aplica en la base de datos con políticas ligadas a la identidad del usuario que inició sesión, o se aplica detrás de un endpoint de servidor que el navegador no puede rodear. Cualquier respuesta que incluya el navegador es un no.
Las buenas respuestas suenan así: la política está en la tabla y ligada a la sesión del usuario; el navegador nunca consulta esa tabla directamente; aquí está el test que lo demuestra. Las respuestas menos útiles suenan así: nuestra aplicación filtra por cuenta; los datos están cifrados; cumplimos con SOC 2. Todo eso puede ser cierto y ninguna de esas respuestas contesta la pregunta.
Es una sola línea, y cabe en una revisión de seguridad de proveedores, que es donde yo la pondría. La respuesta te dice si el multi-tenant se diseñó desde el principio o se añadió después.
Preguntas frecuentes
¿Por qué publicarlo en lugar de dejarlo en un repositorio privado?
Porque un artefacto público se puede revisar y uno privado no. Los posts que escribí antes sobre cómo construí esto hacían afirmaciones; un repositorio con las migraciones, las políticas y los tests es algo que un lector puede verificar, incluidas las partes que hice mal. Las notas de arquitectura tienen una sección llamada "Trade-offs, y qué haría diferente", y es la razón por la que el repositorio existe en esta forma.
¿La segunda versión será de código abierto?
No. Se desarrolla en privado, y no se espera que la base de código se parezca a esta. Prefiero decirlo claramente antes que dejarlo ambiguo y que la gente clone esto esperando una hoja de ruta.
¿Puedo usar esto en producción?
Yo no lo haría. Es una referencia, no un producto. Las credenciales de demo en los datos de seed son solo para uso local, no hay ningún compromiso de soporte, y nadie lo está corrigiendo frente a futuros cambios de dependencias. Es una buena cosa para leer y de la que tomar prestado, y una mala cosa sobre la que montar un negocio.
¿Necesito una clave de IA para probarlo?
No. Arranca en modo demo, donde los agentes devuelven respuestas predefinidas claramente etiquetadas en lugar de llamar a un modelo. Puedes recorrer toda la aplicación — cada agente, los flujos de clientes de la agencia, el selector de idioma — sin una clave y sin gastar nada. Cambiar a llamadas reales al modelo es un ajuste y una clave. La primera vez tardé más de diez minutos: necesita Docker corriendo, Node, Python, Poetry y la CLI de Supabase, y la parte lenta es una descarga grande.
Escribí mucho sobre cómo construí esto mientras lo construía, y los dos posts con los que empezaría son por qué construí el multi-tenant el día dos y qué pasó cuando lo reconstruí el día sesenta y siete. Si solo uno de los dos merece tu tiempo, es el segundo — es en el que la arquitectura resultó estar equivocada.
El código está en github.com/chandlernguyen/stratum-oss, y el test que vigila el error que envié está en tests/automated/test_rls_coverage.py.
Si has enviado un sistema multi-tenant y encontraste una tercera trampa silenciosa que yo no mencioné, de verdad me gustaría que me lo contaras — esas son las que vale la pena coleccionar.
Comentarios, sugerencias y qué pasa con la v1
Prefiero que esto no sea una transmisión en un solo sentido, así que esta es la versión honesta de qué esperar.
Lo que recibiría con gusto: reportes de bugs si algo en el repositorio está simplemente mal. Sugerencias sobre las partes que podrían ser más claras, o más simples, o hacerse con menos código. Notas de cualquiera que haya intentado ejecutarlo y se haya topado con algo que el README no cubre. Pull requests, si encuentras un problema real y quieres arreglarlo. Y si le haces un fork y construyes algo tuyo, me gustaría saber qué cambiaste y por qué — esa es la retroalimentación más interesante de todas, porque tuviste que tomar las decisiones de verdad.
Lo que puedo prometer: no mucho, y prefiero decirlo antes que dar a entender otra cosa. No es un proyecto en desarrollo activo, no estoy llevando una mesa de soporte, y no puedo comprometerme con un tiempo de respuesta. Algunas sugerencias las aplicaré. Otras las leeré, estaré de acuerdo, y nunca llegaré a ellas. Es la versión realista de un proyecto paralelo que ya tiene un sucesor.
Qué pasa con la v1: se queda publicada tal como está. No la desarrollo más, así que no cuentes con nuevas versiones. Pero no voy a fingir que está sellada — el código es público, la licencia te permite llevarlo en cualquier dirección, y si algo está roto o es genuinamente poco claro, no tengo ninguna buena razón para dejarlo así por principio.
La forma más sencilla de contactarme es un issue en el repositorio, o un email si prefieres no hacerlo en público.
Eso es todo por mi parte por ahora.
Un abrazo, Chandler