Hoje tornei open source a primeira versão da minha plataforma de marketing.
Ela se chama STRAŦUM. Nove agentes de IA, um workspace de agência com separação de dados por cliente, dez idiomas. Construí em 2025, enquanto aprendia a programar por conta própria. Depois comecei uma segunda versão e, em vez de manter a primeira trancada a sete chaves, eu a doei.
Se você quiser dar uma olhada antes de ler o resto: github.com/chandlernguyen/stratum-oss
git clone https://github.com/chandlernguyen/stratum-oss
Se você lidera times de marketing e nunca vai abrir esse repositório, pule para "Se você nunca vai ler o código" lá perto do fim. Essa seção é para você e é curta.
Quero ser preciso sobre o que esse repositório é, porque "open source" pode significar muitas coisas, e a maioria delas exagera o que isto aqui é.
O que eu realmente publiquei
O STRAŦUM v1 está publicado e não está em desenvolvimento ativo. O código é público sob a licença MIT. Não há roadmap, não há cronograma de releases e não há compromisso de suporte — o README diz isso, e o guia de contribuição repete.
O STRAŦUM é publicado como uma implementação de referência. É algo para ler, para fazer um fork, ou para aproveitar partes. Não é algo para se apoiar. Ninguém está mantendo o projeto contra mudanças futuras nas dependências, e o repositório lista os advisories abertos que conhece, em vez de fingir que eles não existem.
O que eu não estou dizendo é que isso nunca vai mudar. Não vou continuar desenvolvendo, então é melhor planejar como se fosse ficar do jeito que está — mas eu leio o que as pessoas me mandam, e se alguém relatar um bug ou sugerir algo que deixe o código mais claro, não vou fingir que não vi.
Publicá-lo também significou provar que nada confidencial foi junto. Há um script no repositório que escaneia cada arquivo versionado em busca de credenciais, chaves privadas, tokens de provedores e identificadores de produção, e ele roda como o primeiro job da CI — antes dos testes, porque uma chave vazada entra no histórico no momento em que é enviada, e apagá-la depois não desfaz isso.
Eis o que tem nele:
- Nove agentes — estratégia, persona, conteúdo, inteligência de performance, inteligência competitiva, planejamento de campanhas, sucesso do cliente e mais dois. Cada um é uma subclasse de um agente base, compartilhando uma estrutura de prompt, um registro de ferramentas e contexto progressivo.
- Dois schemas do Postgres. Os dados de cada negócio individual ficam em um, os dados da agência em outro, com o Row Level Security como fronteira de isolamento.
- Dez idiomas, com o locale viajando até a API em um header, para que os metadados gerados correspondam ao idioma da interface.
- Um modo demo que não precisa de chave de API. Os agentes retornam respostas prontas claramente identificadas em vez de chamar um modelo, então você pode navegar pelo aplicativo inteiro sem uma conta de provedor.
Se você quiser construir a sua própria versão disto
Esse é o uso que eu mais gostaria de ver, então vale dizer com todas as letras em vez de deixar implícito.
Se o que você quer de verdade é o seu próprio sistema de agentes de marketing — seus próprios agentes, seus próprios prompts, seu próprio schema, seu próprio produto em cima — então faça um fork do repositório e construa o seu. É para isso que a licença MIT existe, e esse é o uso pretendido, não uma brecha. Não há exigência de atribuição além de manter o arquivo de licença, e não precisa falar comigo. Pegue as partes úteis, jogue fora as que não são, e mude tudo com que você não concorda.
Eu prefiro ver uma dúzia de versões diferentes disto a uma única versão que só eu toquei.
Por que publicar a v1 em vez de ficar com ela só para mim
Deixei isso parado por dez meses porque assumi que ninguém ia querer uma v1 que eu já tinha parado de desenvolver. Essa suposição estava errada, e o motivo de ela estar errada vale mais do que o próprio repositório.
A segunda versão segue outro caminho. O que estou construindo agora não se parece muito com esta base de código, e não vai ser um diff contra ela. Manter a v1 privada significaria vê-la virar, aos poucos, uma peça de museu que ninguém pode visitar.
O motivo melhor é que uma aplicação agêntica multi-tenant que funciona é um artefato mais útil do que um diagrama de arquitetura. Quando eu estava aprendendo, o que ajudou não foi o explicador conceitual — foi encontrar um projeto real e ler como outra pessoa tinha de fato organizado as coisas. O raciocínio que se perde no explicador é justamente o raciocínio que mais importa.
Escrevi quatro coisas nas notas de arquitetura do repositório que eu errei e não faria de novo, e deixei todas as quatro lá. A primeira é um bug de segurança.
O erro que foi para produção
Uma tabela saiu sem o Row Level Security ativado.
public.notification_push_deliveries foi criada sem a linha que ativa o RLS e concedida aos papéis anonymous e authenticated com privilégios totais — incluindo TRUNCATE — sem nenhuma policy definida. Ela fica no schema que a Data API expõe, e a chave anônima vai dentro do bundle do navegador. Então, naquele período, qualquer pessoa com essa chave podia ler a tabela ou modificá-la. Ela guarda identificadores de dispositivos para push, o que fazia disso um problema de exposição de dados, não algo teórico.
Encontrei isso em uma revisão de segurança. Não em um bug report, e não nos testes, porque a aplicação funcionou perfeitamente o tempo todo. Nada dispara um erro quando falta RLS em uma tabela. As queries funcionam, a feature funciona, e a permissão que não deveria existir simplesmente está lá, sem fazer nada visível até alguém olhar.
Hoje está fechado. O RLS está ativado na tabela, sem nenhuma policy, e os grants dos papéis do navegador foram revogados, então a requisição que um dia teria retornado linhas volta agora como um erro de permissão. Estou descrevendo isso no passado de propósito: era assim que o código estava, não é assim que ele está hoje.
É nessa parte que quero ser preciso: isso não foi uma decisão de design que eu acertei. Foi uma regressão que eu errei e só descobri depois.
Então parei de confiar na minha memória
O que fiz em seguida é a única parte disto que eu entregaria a outra pessoa com confiança.
Escrevi um teste que não cita nenhuma tabela específica. Ele percorre o schema e verifica a regra: toda tabela em um schema exposto tem o Row Level Security ativado, e nenhuma view materializada com escopo de tenant é legível por um papel exposto ao navegador. Se eu adicionar uma tabela no mês que vem e esquecer, o teste falha, e falha naquilo que eu esqueci, não em uma lista que escrevi quando ainda lembrava.
Duas ressalvas honestas, e eu prefiro que um leitor me cobre por elas a que ele as descubra sozinho depois:
- A metade dessa checagem que trata das views materializadas cobre o schema
publice não o schemaagency. O lado da agência tem uma exposição própria que eu não fechei. - O teste se marca como skipped quando não consegue acessar um banco de dados. Um teste skipped fica verde, o que significa que um ambiente quebrado pode esconder um invariante falhando.
Se eu tirasse um hábito de tudo isso, seria este: quando encontro um bug com essa cara, escrevo o teste que teria pego o problema — e escrevo contra a regra, não contra o objeto. Um teste que cita os três objetos de que eu lembrava protege só esses três. Um teste que verifica a regra protege o que eu adicionar na semana que vem e esquecer.
A segunda armadilha
Tem uma segunda que eu acertei por sorte, não por design, e é a que eu mais ouço repetida em conversas com fornecedores.
Um filtro do lado do cliente não é uma fronteira. Se a sua aplicação filtra linhas por organização no navegador — .eq('org_id', ...) e amigos — então o isolamento roda na máquina do usuário, o que significa que dá para removê-lo com as ferramentas de desenvolvedor. Qualquer coisa removível é uma preferência de exibição, não um controle de segurança. A visibilidade das linhas tem que ser garantida no banco de dados ou atrás de um endpoint de servidor que o chamador não consiga contornar.
As views materializadas são o parente incômodo desse problema. Elas não podem ter Row Level Security de jeito nenhum. Um grant de SELECT em uma view materializada retorna as linhas de todos os tenants, e em uma migration ele parece idêntico ao mesmo grant em uma tabela, onde as policies ainda o limitariam. Por exatamente esse motivo, o repositório revoga os grants de papéis expostos ao navegador em qualquer coisa com escopo de tenant que seja materializada.
A que eu ainda não terminei
O Row Level Security não consegue restringir TRUNCATE. É um privilégio de tabela, não de linha, então as policies não se aplicam a ele. Os grants básicos do repositório dão aos papéis do navegador ALL em um grande número de tabelas — o que inclui TRUNCATE — e nada estreita isso depois. Os grants das views materializadas são revogados. Estes não são. Então existem tabelas em que um papel do navegador tem um privilégio que fica fora da fronteira que acabei de passar esta seção inteira descrevendo.
Na prática, isso é latente, não uma porta aberta: a Data API não tem verbo TRUNCATE, os papéis do navegador não conseguem se conectar diretamente ao banco de dados, e um deployment normal não expõe a porta do banco. É um problema de higiene. Mas tem a mesma forma do bug lá do começo deste post — um privilégio que sobrevive em silêncio à própria justificativa — e eu prefiro apontar para ele a deixar um leitor encontrá-lo e ficar se perguntando se eu sabia. Precisa de um revoke nos dois schemas, e de um teste que assegure que nenhum papel do navegador detém um privilégio que passa por cima do RLS.
O que eu não faria de novo
Dois schemas paralelos duplicaram muito DDL. Os dados de agência e de negócio individual têm estruturas paralelas em vez de uma tabela com uma coluna discriminadora de tenant. A separação é genuinamente mais limpa. A duplicação custou mais em manutenção do que a coluna discriminadora teria custado, e na próxima vez eu tomaria a outra decisão.
Function calling manual é muito código. O loop do agente trata as chamadas de ferramenta na mão em vez de usar o function calling automático do provedor, o que mantém o streaming e a execução de ferramentas sob controle da aplicação. O custo é que a aplicação precisa montar as partes de resultado de função por conta própria, e nos modelos atuais essas partes precisam carregar o call id, além do nome da função. Omita o id e você não recebe um erro de schema. Você recebe algo que parece um modelo instável, o que dá uma tarde bem pior. Se streaming com ferramentas já estiver disponível em uma API de nível mais alto, vale a pena revisitar essa troca.
A cadeia de migrations chegou a 321 arquivos. Uma boa parte deles se chamava fix_, _v2 e remove_, porque eu ficava acrescentando correções em vez de editar o que já tinha escrito. O estado real do schema só era possível de saber reexecutando o histórico desde o começo. Os posts que escrevi na época eram mais confiantes sobre aquele período do que o código merecia — em novembro de 2025 eu disse que trinta e três migrations tinham "finalmente resolvido" o multi-tenancy, e continuei escrevendo migrations corretivas por meses depois disso.
O frontend confia nos formatos da API sem validá-los. Há uma validação cuidadosa na entrada — o backend interpreta cada requisição com Pydantic — e nenhuma na saída. O navegador pega a resposta e acredita nela. Um schema compartilhado pegaria o desvio quando um campo mudasse de formato, em vez de deixar isso ser descoberto como uma tela em branco. Somado aos três acima, são as quatro coisas que as notas de arquitetura dizem que eu faria diferente.
O repositório publicado reconstrói a cadeia de migrations como vinte migrations em camadas, agrupadas por preocupação — tabelas, depois funções agrupadas por domínio, views, índices, triggers, policies, grants, jobs agendados e uma passada final de hardening. Isso é aproximadamente verdade, não exatamente, e vale a pena ser honesto sobre onde a coisa escorrega: uma das vinte é um catch-all assumido para funções que não se encaixaram em nenhuma categoria, e a camada de tabelas não está dividida do jeito que os nomes dos arquivos sugerem. O arquivo batizado com o schema compartilhado também cria as oito tabelas da agência, e o arquivo batizado com o schema da agência não contém nenhuma definição de tabela. A divisão em camadas é real; os rótulos é que não são perfeitos.
O que a divisão acerta é justamente a parte que importa para quem vai ler: o schema é uma reorganização pura, verificada ao fazer o dump antes e depois e confirmar que os dois são byte a byte idênticos, afora o token aleatório da ferramenta de dump.
Essa verificação é o único motivo pelo qual eu me dispus a mexer nisso.
O que sobreviveu
O Row Level Security como fronteira de verdade, e não como uma feature para ligar. Quando o design é organizado em torno dele, o isolamento deixa de ser um item de checklist e passa a ser uma propriedade do sistema.
As escritas passam por funções de banco roteadas. O código da aplicação não escolhe qual schema acessar. Ele chama uma função que inspeciona o tipo de organização e redireciona, então a escolha fica em um único lugar, onde um novo caminho de código não tem como esquecer de fazê-la.
O cadastro ignora a entrada do cliente. O trigger de provisionamento não aceita um id de organização nem um papel fornecidos pelo cliente, porque esses dados estão sob controle do usuário. Ele cria uma organização nova e um papel de owner padrão. Coisa pequena, fácil de errar, cara quando você erra.
Construção lazy dos clients. Os serviços constroem seu client do provedor no primeiro uso, não no import. Isso parece uma preferência de estilo, e não é: vários serviços são criados no import do módulo, então a construção ansiosa fazia com que importar a aplicação exigisse uma chave de API, e a falha aparecia como um erro opaco do SDK do provedor antes de qualquer coisa conseguir iniciar. Tornar isso lazy é o que possibilita o modo demo, que é o que permite a um desconhecido avaliar o projeto sem custo nenhum.
Essa última é a decisão com que estou mais satisfeito, e não a tomei pelo motivo que acabou se revelando importante. Tomei para acabar com o crash.
Se você nunca vai ler o código
Esta é a seção que eu teria querido ter quando estava do lado da agência comprando esse tipo de software, então não tem código aqui.
O que vale levar é a diferença entre "nós filtramos por conta" e "o banco de dados não consegue retornar as linhas de outra conta."
Passei a maior parte da minha carreira comprando plataformas e, nos últimos anos, construindo-as, e essa distinção é a que eu não entendi até ter colocado no ar a versão errada dela. Uma é uma regra que o navegador segue. A outra é uma regra que o navegador não consegue quebrar. A maioria das ferramentas tem a primeira e a descreve na linguagem da segunda.
Quando você avalia uma plataforma que vai guardar os dados de vários clientes — inteligência competitiva, dados de performance, definições de audiência, seja lá o que for — a pergunta a fazer não é "ela é segura?". Todo mundo responde que sim. A pergunta é: onde a separação é aplicada, e o que acontece se um desenvolvedor remover o filtro?
Existem duas respostas boas. Ou ela é aplicada no banco de dados por policies atreladas à identidade do usuário autenticado, ou é aplicada atrás de um endpoint de servidor que o navegador não consegue contornar. Qualquer resposta que envolva o navegador é um não.
Respostas boas soam assim: a policy está na tabela e vinculada à sessão do usuário; o navegador nunca consulta aquela tabela diretamente; eis o teste que prova isso. Respostas menos úteis soam assim: nossa aplicação filtra por conta; os dados são criptografados; somos SOC 2 compliant. Tudo isso pode ser verdade, e nada disso responde à pergunta.
É uma linha, e cabe em uma revisão de segurança de fornecedor, que é onde eu colocaria isso. A resposta diz se o multi-tenancy foi pensado desde o design ou adaptado depois.
Perguntas frequentes
Por que publicar em vez de deixar parado em um repositório privado?
Porque um artefato público pode ser verificado e um privado não. Os posts que escrevi antes sobre construir isso faziam afirmações; um repositório com as migrations, as policies e os testes é algo que o leitor consegue verificar, inclusive as partes que eu errei. As notas de arquitetura têm uma seção chamada "Tradeoffs, and what I would do differently", e é por causa dela que o repositório existe nesta forma.
A segunda versão vai ser open source?
Não. Ela é desenvolvida em um repositório privado, e não se espera que a base de código se pareça com esta. Prefiro dizer isso de forma clara a deixar ambíguo e ver gente clonando este repositório esperando um roadmap.
Posso usar isso em produção?
Eu não usaria. É uma referência, não um produto. As credenciais de demo nos dados de seed são só para uso local, não há compromisso de suporte, e ninguém está corrigindo o projeto contra mudanças futuras nas dependências. É uma boa coisa para ler e pegar emprestado, e uma péssima coisa para rodar um negócio em cima.
Preciso de uma chave de IA para testar?
Não. Ele inicia em modo demo, no qual os agentes retornam respostas prontas claramente identificadas em vez de chamar um modelo. Você pode navegar pelo aplicativo inteiro — todos os agentes, os fluxos de clientes da agência, o seletor de idioma — sem chave e sem gastar nada. Mudar para chamadas reais de modelo é uma configuração e uma chave. Mas na primeira vez levei mais de dez minutos: ele precisa do Docker rodando, Node, Python, Poetry e a CLI do Supabase, e a parte lenta é um download grande.
Escrevi muito sobre construir isso enquanto construía, e os dois posts por onde eu começaria são por que construí multi-tenancy no segundo dia e o que aconteceu quando reconstruí tudo no sexagésimo sétimo dia. Se só um deles valer o seu tempo, é o segundo — aquele em que a arquitetura se revelou errada.
O código está em github.com/chandlernguyen/stratum-oss, e o teste que protege contra o erro que eu coloquei no ar está em tests/automated/test_rls_coverage.py.
Se você já colocou um sistema multi-tenant no ar e encontrou uma terceira armadilha silenciosa que eu não mencionei, eu realmente gostaria de ouvir sobre ela — essas são as que valem a pena colecionar.
Feedback, sugestões e o que acontece com a v1
Eu prefiro que isto não seja uma transmissão de mão única, então aqui vai a versão honesta do que esperar.
O que eu adoraria receber: relatos de bug se algo no repositório estiver simplesmente errado. Sugestões sobre as partes que poderiam ser mais claras, ou mais simples, ou feitas com menos código. Recados de quem tentou rodar e esbarrou em algo que o README não cobre. Pull requests, se você encontrar um problema de verdade e quiser corrigi-lo. E se você fizer um fork e construir algo seu, eu gostaria de saber o que você mudou e por quê — esse é o feedback mais interessante de todos, porque você teve que tomar as decisões de verdade.
O que eu posso prometer: não muita coisa, e prefiro dizer isso a dar a entender o contrário. Este não é um projeto em desenvolvimento ativo, não estou mantendo um suporte, e não posso prometer prazo de resposta. Em algumas sugestões eu vou mexer. Outras eu vou ler, concordar e nunca chegar a fazer. Essa é a versão realista de um projeto paralelo que já tem um sucessor.
O que acontece com a v1: ela continua publicada como está. Não vou desenvolvê-la mais, então não conte com novas releases. Mas não vou fingir que ela está lacrada — o código é público, a licença permite que você leve isso para qualquer direção, e se algo está quebrado ou genuinamente pouco claro, não há um bom motivo para eu deixar assim por princípio.
O jeito mais simples de falar comigo é abrir uma issue no repositório, ou mandar um e-mail, se você preferir não tornar isso público.
É isso por enquanto, pessoal.
Abraços, Chandler