Ir al contenido

v0.2 — Multi-tenant + Onboarding Self-serve

Esta página aún no está disponible en tu idioma.

A parte que vamos analisar nesta versão: o onboarding da associação + a autenticação multi-tenant. Modelado como EventStorming antes de codar.

Onboarding + Auth Multi-tenant — EventStorming v0.1.0

Como isolar event streams por tenant? SSO / convite de usuário entram na v0.1?
Actor Operador da plataforma
Command Provisionar Associação
Aggregate Associação (Tenant)
Domain Event Associação Provisionada
Policy Ao provisionar → semear admin
Command Semear Usuário Admin (cred. temp · senha=email)
Aggregate Identidade
Domain Event Admin Semeado · must_change_credentials
Actor Admin da Associação
Read Model Página de Login
Command Autenticar
Aggregate Sessão
Domain Event Usuário Autenticado
Policy Emitir JWT { tenant, papel, must_change_credentials }
Policy Se must_change_credentials → abrir onboarding no agente
Read Model MCP App: onboarding-credential-setup
Command Definir Acesso Real (e-mail + senha)
Aggregate Identidade
Domain Event Credenciais Definidas · Cred. Temp Revogada
Read Model Dashboard do Tenant (escopado)
Actor Admin
Command Cadastrar Membro
Aggregate Membership
Domain Event Membro Cadastrado
Read Model Lista de Membros (do tenant)
Legenda Domain Event Command Actor Policy Read Model Aggregate External System Hotspot Pivotal (marco)

Operador: liberar uma associação (MCP Operacional interno)

Seção intitulada “Operador: liberar uma associação (MCP Operacional interno)”

O operador (Gabriel) nunca abre um formulário de cadastro. Em vez disso, abre o Claude Code e conversa com o MCP Operacional — um servidor MCP interno, separado do apps/mcp que as associações usam. Em segundos, o tenant está provisionado e a credencial inicial entregue. Não há tela de signup, não há fluxo manual: o operador descreve o que quer em linguagem natural e o MCP executa as primitivas abaixo.

ToolO que fazNível
provision_association(nome, slug, plano)Cria tenant isolado + namespace de streams + plano de acessoOperador
seed_admin_user(associação, email, senha_temporária)Cria o primeiro usuário admin com flag must_change_credentialsOperador
list_associations()Lista todos os tenants ativos com status e planoOperador (leitura)
suspend_association(id)Pausa acesso ao tenant (mantém dados)Operador
reset_credential(user)Força novo must_change_credentials num usuário existenteOperador

O operador quer libertar a “Casa da Mata”. Dois comandos no Claude Code:

provision_association("Casa da Mata", "casa-da-mata", "gerenciado-basico")
seed_admin_user("casa-da-mata", "casadamata@cannabr.org", "casadamata@cannabr.org")

O MCP provisiona o tenant e cria o usuário admin com a credencial temporária no padrão senha = e-mail (casadamata@cannabr.org / casadamata@cannabr.org) — simples de repassar, válida só para o primeiro acesso e com troca obrigatória (must_change_credentials). A associação recebe esse acesso e o muda na primeira entrada.

sequenceDiagram
    actor OP as Operador (Gabriel)
    participant CC as Claude Code
    participant MO as MCP Operacional
    participant DB as Plataforma (DB + Streams)

    OP->>CC: "Provisiona Casa da Mata, plano básico"
    CC->>MO: provision_association("Casa da Mata", "casa-da-mata", "gerenciado-basico")
    MO->>DB: Cria tenant + namespace streams
    DB-->>MO: tenant_id gerado
    MO-->>CC: { ok: true, tenant_id: "casa-da-mata" }

    CC->>MO: seed_admin_user("casa-da-mata", "casadamata@cannabr.org", "casadamata@cannabr.org")
    MO->>DB: Cria usuário (senha = email · must_change_credentials: true)
    DB-->>MO: user criado
    MO-->>CC: { ok: true, must_change_credentials: true }

    CC-->>OP: "Associação pronta. Acesso: casadamata@cannabr.org / senha = o próprio e-mail (troca no 1º acesso)."
    Note over OP,DB: Tenant isolado — streams e read-models namespaced por "casa-da-mata"

Quando o admin da associação usa pela primeira vez a credencial temporária entregue pelo operador, o agente detecta automaticamente o flag must_change_credentials no JWT. Em vez de bloquear o acesso com uma mensagem de erro, o agente saúda o usuário pelo nome da associação, explica o contexto em linguagem natural e abre um MCP App diretamente na conversa — o onboarding-credential-setup — onde o usuário define e-mail real e senha permanente antes de qualquer outra ação. Após a confirmação, a credencial temporária é revogada, o JWT é reemitido com o novo acesso e o usuário segue direto para o dashboard do tenant.

Esboço do MCP App de primeiro acesso

App ao vivo com dados de exemplo — é o widget real que o agente abre no primeiro acesso. No produto, o set_initial_credentials substitui o acesso temporário pelo definitivo.

  1. Agente detecta must_change_credentials — lê o claim no JWT logo após a autenticação inicial com senha temporária.
  2. Abre onboarding-credential-setup — o MCP App aparece na interface; o usuário define e-mail real e senha permanente (mínimo 8 chars, troca obrigatória confirmada).
  3. set_initial_credentials executado — credencial temporária revogada, JWT reemitido com acesso normal, usuário redirecionado ao dashboard do tenant.
sequenceDiagram
    actor U as Admin da Associação
    participant AG as Agente canna
    participant APP as MCP App (onboarding-credential-setup)
    participant ID as Identidade (Plataforma)

    U->>AG: Login com credencial temporária
    AG->>ID: Autentica (JWT temporário)
    ID-->>AG: JWT { must_change_credentials: true }

    AG-->>U: "Bem-vindo(a) à Casa da Mata! Defina seu acesso real."
    AG->>APP: Abre onboarding-credential-setup

    U->>APP: Preenche e-mail real + nova senha
    APP->>AG: set_initial_credentials(email, senha)
    AG->>ID: Atualiza credencial, revoga temp
    ID-->>AG: Credencial permanente ativa

    AG-->>U: Redireciona para dashboard do tenant
    Note over U,ID: Credencial temporária revogada — acesso definitivo ativo

Cadastro de membro: chat → form preenchido → confirma

Seção intitulada “Cadastro de membro: chat → form preenchido → confirma”

A melhor UX combina velocidade do chat + confiança do formulário. O usuário escreve em linguagem natural — o agente extrai os campos e abre o MCP App de cadastro já preenchido. O usuário revisa e confirma (ou corrige). NL para velocidade; form para revisão, validação e confiança antes de gravar.

Cadastrar Fulano de Tal, CPF 123.456.789-00, nascido em 12/04/1989, contato fulano@email.com, papel: associado.

Extraí os dados — confira e confirme:

App ao vivo — o form aparece preenchido pelo que o agente extraiu; o usuário revisa e confirma (register_member). Bate o melhor dos dois mundos: sem redigitar (NL ≠ form blind-write) e sem esconder o dado num chat (form = revisão estruturada antes de gravar).

O operador vive no chat do Claude Code. Toda use case da camada de aplicação devolve o entregável humano pronto (a mensagem, o link, o recibo), não só o registro/ID — o operador cola e segue. Esse é o detalhe de UX agent-first que fecha o loop.

MCP Operacional  ·  Resultado da ferramenta

Convite Casa da Mata São Vicente

Olá Thiago, sua associação está provisionada no canna-br. Você é o administrador inicial (Diretor).

Primeiro acesso (credencial temporária):

Login
thiago@casa-da-mata-sv.cannabr.org
Senha
thiago@casa-da-mata-sv.cannabr.org (igual ao login)

No 1º login você define a senha real + ativa 2FA. A credencial temporária expira em 7 dias.

json provision_association + seed_admin_user → output
{
  "association": {
    "id": "assoc_…",
    "slug": "casa-da-mata-sv",
    "name": "Casa da Mata São Vicente",
    "tenant": "casa-da-mata-sv"
  },
  "admin": {
    "name": "Thiago",
    "role": "Diretor",
    "login": "thiago@casa-da-mata-sv.cannabr.org",
    "must_change_credentials": true,
    "expires_at": "2026-06-18T00:00:00Z"
  },
  "invite": {
    "subject": "Convite — Casa da Mata São Vicente no cannabr.org",
    "body_markdown": "…",
    "body_plain": "…",
    "copy_paste": "🌿 Convite — Casa da Mata São Vicente …"
  }
}

A chamada foi provision_association + seed_admin_user via o MCP Operacional; o campo que importa para a UX é invite.copy_paste.

Cada use case devolve hoje apenas o registro. A coluna "Devia devolver" mostra o artefato agent-first que fecha o loop para o operador ou membro.
Use Case Devolve hoje Devia devolver (artefato) Versão
provision / seed_admin ids convite admin copy-paste v0.1.0
reset_credential ok mensagem "nova senha temporária" pronta v0.1.0
registerMember member id boas-vindas + link de 1º acesso do membro (WhatsApp-ready) + carteirinha digital v0.1.0
viewMemberQuota (perto do limite) número nudge "40/45g este mês" pro membro v0.1.0
1º login concluído coach "bem-vindo · próximos 3 passos" v0.1.0
recordDispensation event recibo de dispensação pro paciente (PDF / texto) v0.2
generate_traceability_report data relatório pronto pro grupo da diretoria v0.2
submit_sngpc protocolo confirmação de protocolo copy-paste v0.2
explain_compliance_gap gaps checklist de remediação acionável v0.2
membership / prescrição expirando lembrete de renovação pronto v0.2

Cada linha abaixo mapeia um use case ao artefato que o operador ou membro realmente precisa — não o id ou ok, mas o texto, link ou recibo pronto pra usar.

Por quê

O domínio precisa ser legível para humano e para o agente. Use cases semânticos = linguagem ubíqua que os dois entendem.

Como

A camada @canna/app-services expõe use cases; o MCP os expõe as-is — 1 use case = 1 tool; cada um aparece numa ou mais superfícies (MCP · Web · MCP App).

O quê

O catálogo abaixo — cada use case, o que faz, e onde é exposto.

Legenda muda estado lê estado exposto como tool (NL → chamada) superfície web própria (REST/página) MCP App que renderiza (query) ou invoca (command) ausente / via chat NL
Use Case
Tipo
MCP
Web
App
Onboarding & Identidade (v0.1.0)
provisionAssociation Cria uma nova associação (tenant) isolada na instância.
Command
MCP provision_association
Web
App
seedAdminUser Semeia o primeiro admin com credencial temporária (troca obrigatória).
Command
MCP seed_admin_user
Web
App
listAssociations Lista os tenants ativos com status e plano.
Query
MCP list_associations
Web
App candidato
authenticate Autentica o usuário e emite o JWT com tenant + papel. via Zitadel (OIDC) — claims tenant+papel no JWT.
Command
MCP
Web Zitadel (managed auth)
App
setInitialCredentials Troca a credencial temporária pelo acesso real no 1º login.
Command
MCP set_initial_credentials
Web
App onboarding-credential-setup
resetCredential Força novo must_change_credentials num usuário existente.
Command
MCP reset_credential
Web
App
Membership mínima (v0.1.0)
registerMember Cadastra um novo associado no tenant (identidade: nome, CPF, contato, papel).
Command
MCP register_member
Web
App member-registration
getMember Carrega os dados de identidade de um associado do tenant.
Query
MCP get_member
Web
App candidato
listMembers Lista os membros do tenant por status (ciclo de vida).
Query
MCP list_members
Web
App MemberLifecycleBoard
viewMemberQuota Visualiza a cota do membro (somente leitura — a escrita da cota entra na v0.2).
Query
MCP get_member_quota
Web
App MemberQuotaCard
Próximas versões (v0.2+) — fora do escopo de v0.1.0
validatePrescription v0.2 · Valida a prescrição e escreve a cota mensal do membro.
Command
MCP validate_prescription
Web
App candidato
grantConsent v0.2 · Registra o consentimento LGPD do associado.
Command
MCP grant_consent
Web
App
revokeConsent v0.2 · Revoga o consentimento (gatilho de anonimização).
Command
MCP revoke_consent
Web
App
anonymizeMember v0.2 · Apaga dados pessoais via crypto-deletion (LGPD Art. 18).
Command
MCP anonymize_member
Web
App
createLot v0.2 · Cria um lote em quarentena (Inventory).
Command
MCP create_lot
Web
App
releaseLot v0.2 · Libera um lote aprovado para dispensação (Inventory).
Command
MCP release_lot
Web
App candidato
recallLot v0.2 · Recolhe um lote (contaminação / qualidade) (Inventory).
Command
MCP recall_lot
Web
App
recordDispensation v0.2 · Registra dispensação (dispensa + cota + lote) (Dispensation).
Command
MCP record_dispensation
Web
App DispensationForm
submitSngpc v0.2 · Submete o relatório SNGPC à ANVISA (consumidor assíncrono via NATS).
Command
MCP submit_sngpc
Web
App
suspendMember v0.2 · Suspende temporariamente um associado (bloqueia dispensações sem excluir dados).
Command
MCP suspend_member
Web
App
reinstateMember v0.2 · Reintegra um associado suspenso, restaurando acesso às dispensações.
Command
MCP reinstate_member
Web
App
quarantineLot v0.2 · Coloca um lote em quarentena preventiva pendente de análise de qualidade.
Command
MCP quarantine_lot
Web
App
loadLotState v0.2 · Carrega o estado atual de um lote (quarentena, liberado, recolhido, esgotado).
Query
MCP get_lot_state
Web
App candidato
loadAssociationDispensations v0.2 · Lista todas as dispensações da associação com filtros de período e membro.
Query
MCP list_association_dispensations
Web
App DispensationBoard

Use cases sem App ainda são acionados via chat NL (linguagem natural → tool call). App: candidato = widget planejado, ainda não construído. Use cases nomeados (ex: DispensationForm) já têm widget live.

Tese: v0.1.0 é a menor versão que já se usa: sobe-se uma instância canna (self-hostada ou gerenciada), uma associação faz onboarding, seus usuários entram por um login multi-tenant e fazem algumas operações básicas. É a fundação do produto — não o tronco regulatório completo (esse entra nos minors seguintes).

O valor vem dos minors. Cada minor entrega algo que alguém usa. O domain kernel + event store (o que chamávamos “v0.1 Blueprint” / “v0.2 Kernel”) são fundações pré-0.1 — alicerce de código, não valor de usuário. A numeração reflete valor entregue.

Por que isso é a fatia usável e não o spine de dispensação? Porque sem porta de entrada — instância no ar, associação cadastrada, login que isola dados por tenant — não há nada pra usar. Dispensação sem onboarding + auth + multi-tenant é uma demo, não um produto. v0.1.0 entrega a porta; v0.2+ entrega o tronco regulatório por trás dela.

O momento usável (o que dá pra demonstrar no fim de v0.1.0):

Um operador sobe a instância (Docker Compose / Kamal). Cria a associação no onboarding. Um usuário da associação faz login (multi-tenant, dados isolados). Dentro, cadastra alguns membros e vê um painel escopado só à sua associação. Outra associação na mesma instância não enxerga nada disso.

A pergunta que v0.1.0 responde: como se vende e se entrega isso?

ModoQuemReceitav0.1.0 entrega
Gerenciado (multi-tenant)Plataforma hospeda N associações numa instânciaInfra = receita (tese infraeconomics)Login multi-tenant + isolamento por tenant + onboarding
Self-host (OSS)Federação/associação roda a própria instânciaComunidade / licença comercial p/ embedMesmo binário, deploy Docker/Kamal documentado

O trabalho concreto de v0.1.0 é a página de login multi-tenant + o isolamento de dados por associação (tenant). Mesmo código serve os dois modos — só muda quem opera o deploy. Isso ancora o GTM: o produto é vendável (gerenciado) e aberto (self-host) desde a v0.1.0.

flowchart TD
    SH["Self-host (OSS, federação)"]
    MG["Gerenciado (infra = receita)"]

    SH --> INST
    MG --> INST

    INST["Instância canna — 1 deploy\napps/api · apps/mcp · web\nDocker Compose / Kamal"]

    INST --> LOGIN["Zitadel\nauth gerenciado · self-host OSS\nOIDC · emite JWT { tenant, papel }"]
    INST --> SDB["SurrealDB · ns=canna\ninfra viva do manager\nevent store (es_stream/es_event)\n+ read-models LIVE SELECT"]
    INST -. "async (v0.2)" .-> NATS["NATS JetStream\nmessage bus\nSNGPC / notificações / fan-out"]

    LOGIN --> SDB
    SDB --> A["Assoc. A\nnamespace / streams A"]
    SDB --> B["Assoc. B\nnamespace / streams B"]
    SDB --> C["Assoc. C\nnamespace / streams C"]

    A --> RM["read-models\nLIVE SELECT · por tenant"]
    B --> RM
    C --> RM

    style INST fill:#0b1a15,stroke:#10b981,color:#6ee7b7
    style LOGIN fill:#0b1a15,stroke:#5B9BD5,color:#bdd9f7
    style SDB fill:#0b1a15,stroke:#10b981,color:#6ee7b7
    style NATS fill:#0b1a15,stroke:#f59e0b,color:#fde68a
    style A fill:#0b1a15,stroke:#F9E79F,color:#fdf0c0
    style B fill:#0b1a15,stroke:#F9E79F,color:#fdf0c0
    style C fill:#0b1a15,stroke:#F9E79F,color:#fdf0c0
    style RM fill:#0b1a15,stroke:#82E0AA,color:#b3f0cb
    style SH fill:#0f172a,stroke:#475569,color:#94a3b8
    style MG fill:#0f172a,stroke:#475569,color:#94a3b8

Isolamento: cada request carrega o tenant (claim no JWT → header x-canna-association); cada associação é um namespace SurrealDB próprio — event streams e read-models nunca cruzam. Read-models fazem push pra UI via LIVE SELECT (sem bus em v0.1). O NATS JetStream entra só em v0.2, quando aparece consumidor assíncrono (SNGPC, notificações, fan-out entre instâncias).

Velocidade primeiro, infra gerenciada. A oferta gerenciada roda em Zitadel Cloud · OIDC/PKCE · região EU · free tier — multi-tenant nativo com organizações isoladas, e JWT que já carrega org_id + roles — exatamente os claims { tenant, papel } que o canna precisa sem camada de transformação. O app user-facing autentica via PKCE (fluxo OIDC público, sem secret no cliente). O mesmo binário é OSS (AGPL) e self-hostável: quem preferir soberania total instala a própria instância sem mudar uma linha de código da aplicação.

Database agêntico, infra já viva. O event store de v0.1.0 é SurrealDB (ns=canna) — rodando na mesma instância do manager (VPS interna), zero infra nova pra provisionar. É um engine multi-model que colapsa a stack: document + grafo (cadeia de custódia Membro→Lote→Dispensação) + vetor (RAG sobre RDC/ANVISA pro agente) + CHANGEFEED (trilha imutável LGPD art. 37) + LIVE SELECT (read-models fazem push, sem bus) — num só processo. O pattern es_stream/es_event com optimistic concurrency já está provado (o manager roda isso hoje). O domínio fica intacto atrás do port CannaEventStore — Postgres/Emmett continua sendo o caminho self-host (dual model).

O bus chega quando precisa. O NATS JetStream (também vivo) é o message bus — distribui eventos pra consumidores assíncronos. Em v0.1.0 não há consumidor async (cadastrar membro + ver painel é síncrono via LIVE SELECT), então o bus não entra ainda. Ele aparece em v0.2: submissão SNGPC assíncrona, notificações, políticas cross-context, fan-out entre instâncias/federação. Distinção que importa: event store (SurrealDB, verdade append-only por stream) ≠ message bus (NATS, distribuição). Ver ADR-003.

Arquitetura: app fino, domínio isolado (polylith)

Seção intitulada “Arquitetura: app fino, domínio isolado (polylith)”

O canna segue o padrão polylith: o código de negócio vive em packages/ (components reutilizáveis), e as aplicações em apps/ são bases finas que apenas compõem esses components. Nenhuma regra de domínio vive numa base.

Domínio isolado. packages/domain é TypeScript puro, event-sourced via Emmett, sem nenhuma dependência de infra, HTTP ou driver de banco. Todas as invariantes e regras de negócio vivem exclusivamente aqui.

Use cases como orquestração. packages/app-services orquestra: carrega o stream de eventos → decide (via domain) → faz append. É a camada que o MCP expõe diretamente, 1 use case = 1 tool.

Bases finas. apps/api (Fastify), apps/mcp (MCP server :3001), apps/agent (Next.js + assistant-ui) e apps/docs (este docsite) são camadas de transporte. Compõem os components; não contêm regra de negócio.

MCP App widgets compartilhados. O kit packages/ui-apps (widgets MCP App) é renderizado em dois lugares ao mesmo tempo: na galeria do docsite (aprovação/preview por Gabriel) e no app do produto (agente abre o widget na conversa). Mesmo artefato, duas superfícies — como o princípio “App is a Tool” exige.

flowchart LR
    subgraph CORE["packages/ — components"]
        DOM["domain\nTS puro · Emmett\nzero infra"]
        AS["app-services\norquestra stream\n→ decide → append"]
        RM["read-models\nprojeções de leitura\nnamespaced por tenant"]
        UI["ui-apps\nMCP App widgets\nshared kit"]

        DOM --> AS
        AS --> RM
    end

    subgraph BASES["apps/ — bases finas (transporte only)"]
        API["api\nFastify\ncommands/queries"]
        MCP["mcp\nMCP server :3001\nuse cases as-is"]
        AGT["agent\nNext.js + assistant-ui\nchat + MCP Apps"]
        DOCS["docs\nAstro · este site\ngaleria + roadmap"]
    end

    AS --> API
    AS --> MCP
    UI --> AGT
    UI --> DOCS

    style DOM fill:#0b1a15,stroke:#10b981,color:#6ee7b7
    style AS fill:#0b1a15,stroke:#5B9BD5,color:#bdd9f7
    style RM fill:#0b1a15,stroke:#82E0AA,color:#b3f0cb
    style UI fill:#0b1a15,stroke:#f59e0b,color:#fde68a
    style API fill:#0f172a,stroke:#475569,color:#94a3b8
    style MCP fill:#0f172a,stroke:#475569,color:#94a3b8
    style AGT fill:#0f172a,stroke:#475569,color:#94a3b8
    style DOCS fill:#0f172a,stroke:#475569,color:#94a3b8
    style CORE fill:#050d0a,stroke:#10b981,color:#6ee7b7
    style BASES fill:#0a0f1a,stroke:#475569,color:#94a3b8

O domínio é o núcleo imutável. As bases trocam (Fastify por Hono, Next por Remix) sem mexer em uma linha de regra. Os widgets viajam entre superfícies sem duplicação.

Visão de containers da versão inicial: um operador, N admins de associação, e o que roda dentro de uma instância canna.

flowchart TD
    OP(["👤 Operador\nGabriel"])
    ADMIN(["👤 Admin da\nAssociação"])

    subgraph EXT["Externo"]
        ZIT["🔐 Zitadel\nauth multi-tenant\nOIDC · JWT {tenant,papel}"]
    end

    subgraph INST["🏢 canna-br — 1 instância"]
        DOCS2["📄 Docsite\nAstro · Starlight\ngaleria + roadmap"]
        AGT2["💬 App / Agente\nNext.js · assistant-ui\nrenderiza MCP Apps"]
        API2["⚙️ apps/api\nFastify\ncommands + queries"]
        MCPS["🔧 apps/mcp\nMCP server :3001\nuse cases as-is"]
        MCPO["🛠 MCP Operacional\ninterno · operador-facing\nprovisiona tenants"]
        ES["🗄 SurrealDB · ns=canna\nevent store + read-models LIVE\n(infra do manager)"]
        BUS["📨 NATS JetStream\nmessage bus — async (v0.2)"]
    end

    OP -->|"provisiona via\nlinguagem natural"| MCPO
    ADMIN -->|"usa"| AGT2
    AGT2 -->|"REST commands/queries"| API2
    AGT2 -->|"MCP tools"| MCPS
    API2 -->|"orquestra via\napp-services"| ES
    MCPS -->|"orquestra via\napp-services"| ES
    MCPO -->|"provisiona tenant\n+ seed admin"| ES
    ES -. "eventos (v0.2)" .-> BUS
    AGT2 -->|"autentica\nJWT {tenant,papel}"| ZIT
    API2 -->|"valida JWT"| ZIT
    MCPS -->|"valida JWT"| ZIT

    style OP fill:#0f172a,stroke:#475569,color:#94a3b8
    style ADMIN fill:#0f172a,stroke:#475569,color:#94a3b8
    style ZIT fill:#1a1040,stroke:#7c3aed,color:#c4b5fd
    style DOCS2 fill:#0b1a15,stroke:#475569,color:#6b7280
    style AGT2 fill:#0b1a15,stroke:#10b981,color:#6ee7b7
    style API2 fill:#0b1a15,stroke:#5B9BD5,color:#bdd9f7
    style MCPS fill:#0b1a15,stroke:#5B9BD5,color:#bdd9f7
    style MCPO fill:#0b1a15,stroke:#f59e0b,color:#fde68a
    style ES fill:#0b1a15,stroke:#10b981,color:#6ee7b7
    style BUS fill:#0b1a15,stroke:#f59e0b,color:#fde68a
    style EXT fill:#0d0820,stroke:#4c1d95,color:#7c3aed
    style INST fill:#050d0a,stroke:#10b981,color:#6ee7b7

Tenants são isolados por namespace SurrealDBcasa-da-mata nunca cruza com outra-assoc. Read-models fazem push via LIVE SELECT; o NATS JetStream (bus) só entra em v0.2 pra consumidor assíncrono. O Zitadel é o único ponto de autenticação; a aplicação não armazena senhas.

Dentro de v0.1.0:

CapabilityValorDone-when
Onboarding de associaçãoCria o tenant + primeiro adminForm/fluxo que provisiona uma associação nova e seu usuário admin
Login multi-tenantPorta de entrada isolada por tenantPágina de login → JWT com { tenant, role }; TOTP opcional
Isolamento por tenantDado de uma associação invisível pra outraStreams + read-models namespaced; teste cross-tenant nega acesso
Operações básicasAlgo pra fazer logo de caraCadastrar membro + ver painel escopado ao tenant
Deploy documentado (2 modos)Vendável + self-hostávelDocker Compose / Kamal + doc “suba a sua instância”

Fora de v0.1.0 (próximos minors):

  • Tronco regulatório completo (prescrição → cota → lote → dispensação c/ aprovação RT → SNGPC) → v0.2
  • Cultivo / processamento / laboratório → futuro
  • Financeiro / DRE → futuro
  • Federação entre instâncias → futuro

Done-when (aceite da versão):

Numa instância recém-subida, duas associações fazem onboarding, cada uma loga no seu tenant, cadastra membros e vê só os seus dados. Nenhum vazamento entre tenants. Documentado para self-host e gerenciado.