Verificando acesso...

MÓDULO 3.3

🧠 Memória compartilhada vs privada

3 escopos (user / team / world), SQLite por usuário, vector store com namespace, promoção controlada e garbage collection. O que separa um agente multi-user que respeita LGPD de um vazamento esperando para acontecer.

6
Tópicos
45
Minutos
Builder
Nível
Hands-on
Tipo
1

🪜 Os 3 escopos: user, team, world

Memória sem escopo é vazamento esperando hora. Toda memória do seu agente cai em um e apenas um de três baldes. Decidir o balde é decisão de produto, não de engenharia — engenharia só executa.

🧩 Tabela canônica dos escopos

Escopo Quem vê Onde mora Exemplo concreto
user só o dono SQLite por usuário "Prefiro respostas curtas." / CPF / saldo
team membros do workspace Postgres + namespace Tom de voz da marca / fluxo aprovado
world todos os usuários vector store global Documentação pública / FAQ produto
Regra do user

Default. Tudo nasce aqui. Promoção é ação consciente.

Regra do team

Requer dono + audit log + 1 aprovação.

Regra do world

Só conteúdo que poderia estar no site público.

2

💾 SQLite por usuário (file-per-tenant)

Banco compartilhado com coluna user_id resolve 80% — mas você ainda paga risco de query mal escrita vazando linha. File-per-tenant elimina esse risco fisicamente: cada user é um memory.db separado em disco. Query errada no DB do João não pode retornar nada da Maria.

Estrutura no disco

~/.app/users/
├── 6a2f3c-uuid-joao/
│   ├── memory.db          # SQLite só do João
│   ├── sessions/
│   └── attachments/
├── 91bd-uuid-maria/
│   ├── memory.db          # SQLite só da Maria
│   └── ...
└── _system/
    └── catalog.db         # mapeia uuid → email, plano, status

Classe UserMemory — abre o DB certo, sempre

from pathlib import Path
import sqlite3, json, time

ROOT = Path.home() / ".app/users"

class UserMemory:
    def __init__(self, user_id: str):
        if not user_id:
            raise ValueError("user_id obrigatório — sem default global")
        self.user_id = user_id
        self.path = ROOT / user_id / "memory.db"
        self.path.parent.mkdir(parents=True, exist_ok=True)
        self.db = sqlite3.connect(self.path)
        self._migrate()

    def _migrate(self):
        self.db.executescript("""
            CREATE TABLE IF NOT EXISTS memories(
              id INTEGER PRIMARY KEY,
              kind TEXT NOT NULL,           -- fact | preference | event
              content TEXT NOT NULL,
              created_at INTEGER NOT NULL,
              last_used_at INTEGER,
              uses INTEGER DEFAULT 0
            );
            CREATE INDEX IF NOT EXISTS idx_kind ON memories(kind);
        """)

    def add(self, kind: str, content: str) -> int:
        cur = self.db.execute(
            "INSERT INTO memories(kind, content, created_at) VALUES(?, ?, ?)",
            (kind, content, int(time.time()))
        )
        self.db.commit()
        return cur.lastrowid

    def recall(self, kind: str | None = None) -> list[dict]:
        sql = "SELECT id, kind, content FROM memories"
        args = ()
        if kind:
            sql += " WHERE kind = ?"; args = (kind,)
        return [dict(zip(["id","kind","content"], r))
                for r in self.db.execute(sql, args).fetchall()]

✓ Default user

Construtor exige user_id. Sem fallback global. Falhar cedo > vazar tarde.

✗ Default everything

UserMemory(user_id=None) abrindo um DB compartilhado é o bug mais comum de implementação ingênua. Nunca.

💡 Bônus operacional

File-per-tenant facilita LGPD: "apagar todos os dados do João" vira rm -rf ~/.app/users/<uuid>/. Backup por user também — sem query, sem dump filtrado.

3

🗄️ Vector store compartilhado com namespaces

SQLite resolve fato estruturado. Para memória semântica (RAG, busca por similaridade) você precisa de vetor. Manter um Chroma/Qdrant por user fica caro — a alternativa é um índice único + metadata filter por namespace. Mas a disciplina passa a viver no filter.

Convenção de namespace

# formato: <scope>:<owner_id>
user:6a2f3c-uuid-joao        # memória privada do João
team:acme-marketing          # compartilhada no time
world:public                 # pública, indexada por todos

Chroma com metadata filter — escrita e busca

import chromadb
from uuid import uuid4

client = chromadb.PersistentClient(path="./vec")
col = client.get_or_create_collection("memories")

def remember(text: str, *, scope: str, owner: str):
    ns = f"{scope}:{owner}"
    col.add(
        documents=[text],
        metadatas=[{"namespace": ns, "scope": scope, "owner": owner}],
        ids=[f"{ns}::{uuid4()}"],
    )

def search(query: str, *, scopes: tuple[str, ...] = ("user",), owner: str):
    if not scopes:
        raise ValueError("scopes vazio = busca sem filtro = vazamento")
    # monta namespaces autorizados explicitamente
    allowed = []
    for s in scopes:
        if s == "user":    allowed.append(f"user:{owner}")
        elif s == "team":  allowed += team_ns_for(owner)   # lookup de membership
        elif s == "world": allowed.append("world:public")
    return col.query(
        query_texts=[query],
        n_results=8,
        where={"namespace": {"$in": allowed}},
    )

🚨 Alerta: search sem filter = vazamento

O bug clássico: dev esquece o where= em uma chamada e o índice retorna documentos de qualquer namespace. O usuário vê dado de outro tenant — e nem percebe, porque o LLM parafraseia.

# BAD — sem where, busca em TUDO
col.query(query_texts=[q], n_results=8)

# GOOD — wrapper search() acima força scopes obrigatório
search(q, scopes=("user",), owner=user_id)

Defesa em profundidade: nunca exporte o cliente bruto do vector store para o código de aplicação. Exporte só search() e remember().

💡 Tip: namespace prefix evita query cruzar

Use prefixo no id (user:joao::abc123) além do metadata. Se um dia precisar reindexar manualmente, o próprio ID já carrega a fronteira. Auditoria por grep vira possível.

4

📤 Promover privado → team (/share)

Memória nasce user. Promover para team é decisão explícita: comando /share, confirmação humana, audit log. Nunca automático — nem "ML sugere promover". Vazamento por ML é o pior tipo: você nem sabe onde foi parar.

Comando /share com confirmação

async def cmd_share(ctx, memory_id: int, target_team: str):
    # 1. valida ownership
    mem = ctx.user_mem.get(memory_id)
    if not mem:
        return reply("❌ memória não existe ou não é sua")

    # 2. valida membership no time
    if target_team not in ctx.user.teams:
        return reply(f"❌ você não é membro de {target_team}")

    # 3. preview + confirmação síncrona
    confirmed = await ctx.confirm(
        f"Compartilhar com `{target_team}`?\n\n"
        f"> {mem.content[:200]}\n\n"
        "Membros que verão: " + ", ".join(team_members(target_team))
    )
    if not confirmed:
        return reply("cancelado")

    # 4. cópia (não move) — original fica como user
    new_id = ctx.team_mem(target_team).add(
        kind=mem.kind, content=mem.content,
        source_user=ctx.user.id, source_mem_id=memory_id,
    )

    # 5. audit log imutável
    audit.write({
        "action": "memory.promote",
        "from": f"user:{ctx.user.id}",
        "to":   f"team:{target_team}",
        "memory_id": memory_id, "new_id": new_id,
        "actor": ctx.user.id, "ts": now(),
    })
    return reply(f"✅ compartilhada como #{new_id}")

📥 O que o audit captura

  • actor: quem promoveu
  • from / to: namespace origem e destino
  • memory_id + new_id: rastreável dos dois lados
  • ts: hora UTC, append-only

🔁 Por que copiar e não mover

  • Revogação não destrói histórico do user
  • Comparar versões fica trivial
  • Rollback = só apagar a cópia team
  • LGPD: original ainda pertence ao titular

⚖️ Princípio: promoção é evento, não estado

Não adicione coluna shared_with_team na tabela memories. Cria-se uma nova linha no escopo de destino com origem rastreável. Estado é derivado dos dois registros — não há "meio compartilhado".

5

🔍 Busca consciente do escopo

A regra é cruel e simples: toda chamada a search() declara explicitamente os escopos consultados. Se a lista vier vazia, levanta exceção. Sem default implícito — porque default implícito é como bug de privacidade nasce.

def search(query: str, *, scopes: tuple[str, ...], owner: str):
    if not scopes:
        raise ValueError(
            "scopes não pode ser vazio. "
            "Use scopes=('user',) explicitamente."
        )
    for s in scopes:
        if s not in {"user", "team", "world"}:
            raise ValueError(f"escopo inválido: {s}")
    # ... resolve namespaces e consulta com where filter ...

# Uso típico no agente
async def answer(question: str, ctx):
    # pergunta pessoal — só user
    if is_personal(question):
        hits = search(question, scopes=("user",), owner=ctx.user.id)
    # pergunta sobre processo do time — user + team
    elif is_team_topic(question):
        hits = search(question, scopes=("user", "team"), owner=ctx.user.id)
    # dúvida genérica de produto — todos
    else:
        hits = search(question, scopes=("user", "team", "world"), owner=ctx.user.id)
    return await llm.respond(question, context=hits)
scopes=('user',)

Padrão de pergunta pessoal. Não cruza dado de ninguém.

('user','team')

Operação interna. Cruza com conhecimento do time.

('user','team','world')

Aberto. Use só quando a pergunta admite resposta pública.

📊 Dado: por que essa disciplina paga conta

  • LGPD Art. 52: multa de até 2% do faturamento (limite R$ 50 mi por infração) por vazamento de dados pessoais.
  • ~73% dos incidentes de IA em produção em 2025 envolveram cross-tenant data leak — não jailbreak, não prompt injection. Foi WHERE faltando.
  • Tempo médio de detecção: 47 dias. Vaza, ninguém vê, até o cliente printar.
  • Custo de remediação: notificação ANPD + auditoria + reputação. ~50x mais caro que ter feito certo.
6

🧹 Garbage collection: órfãs, duplicatas, expiradas

Memória cresce monotonicamente. Sem GC, em 6 meses você tem 80% de lixo — duplicatas, fatos obsoletos, memórias de usuários deletados que ficaram órfãs no vector store. Performance cai, custo sobe, e auditoria fica impossível.

Schema com CASCADE — órfãs morrem sozinhas

CREATE TABLE users(
  id          TEXT PRIMARY KEY,
  email       TEXT UNIQUE NOT NULL,
  deleted_at  INTEGER
);

CREATE TABLE memories(
  id            INTEGER PRIMARY KEY,
  user_id       TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  content       TEXT NOT NULL,
  content_hash  TEXT NOT NULL,    -- sha256(normalize(content))
  created_at    INTEGER NOT NULL,
  last_used_at  INTEGER,
  ttl_days      INTEGER,             -- NULL = nunca expira
  UNIQUE(user_id, content_hash)        -- dedup automático na escrita
);
CREATE INDEX idx_mem_lastused ON memories(last_used_at);

Cron de GC — 3 passadas, idempotente

# cron: 0 3 * * *   (todo dia 3h da manhã)
def gc_run(db, vec):
    now = int(time.time())
    stats = {"expired": 0, "orphan_vec": 0, "dedup": 0}

    # 1) Expiração por TTL
    cur = db.execute("""
        DELETE FROM memories
        WHERE ttl_days IS NOT NULL
          AND created_at + (ttl_days * 86400) < ?
        RETURNING id, user_id
    """, (now,))
    expired = cur.fetchall()
    stats["expired"] = len(expired)
    # sincroniza no vector store
    for mem_id, uid in expired:
        vec.delete(ids=[f"user:{uid}::{mem_id}"])

    # 2) Órfãs no vector store (user deletado, CASCADE já tirou do SQLite)
    live_users = {r[0] for r in db.execute(
        "SELECT id FROM users WHERE deleted_at IS NULL")}
    for ns in vec.list_namespaces():
        if ns.startswith("user:") and ns.split(":")[1] not in live_users:
            n = vec.delete_namespace(ns)
            stats["orphan_vec"] += n

    # 3) Dedup defensivo (UNIQUE já protege, mas histórico antigo pode ter)
    cur = db.execute("""
        DELETE FROM memories
        WHERE id NOT IN (
          SELECT MIN(id) FROM memories GROUP BY user_id, content_hash
        )
    """)
    stats["dedup"] = cur.rowcount

    db.commit()
    log.info("gc.done", extra=stats)
    return stats
Passada 1: TTL

Memórias com prazo curto (ex: cache de sessão, 7 dias).

Passada 2: Órfãs

Vector store não tem FK — precisa reconciliar com SQLite.

Passada 3: Dedup

Defensivo. Mantém o ID menor (mais antigo) por hash.

💡 Conceitos-chave do GC

  • Idempotência: rodar 2x não muda resultado. Cron pode falhar e ser reexecutado sem medo.
  • CASCADE no SQLite, manual no vetor: vector store quase sempre não tem FK — a reconciliação é trabalho do cron.
  • Métricas como SLO: exporte stats pro Prometheus. Quando orphan_vec > 0 consistente, há bug em deleção de user.
  • Dry-run primeiro: antes de rodar GC novo em prod, troque DELETE por SELECT e revise o que iria embora.

📝 Resumo do módulo

3 escopos canônicos — user (privado), team (workspace), world (público). Decida o balde antes de escrever a linha.
SQLite file-per-tenant — cada user em ~/.app/users/<uuid>/memory.db. Query errada não cruza fronteira física.
Vector store com namespace — um índice, metadata filter obrigatório, wrapper search() que rejeita scopes vazio.
Promoção como evento auditável/share com confirmação humana, cópia (não move), audit log imutável.
Busca declara escopos — sem default. 73% dos incidentes em 2025 foram WHERE esquecido.
GC noturno em 3 passadas — TTL, órfãs no vetor, dedup. CASCADE no SQLite, reconciliação manual no vector store.

Próximo módulo:

3.4 — Orquestrador: chief-of-staff que coordena agentes especializados por usuário