v0.2 — Multi-tenant + Onboarding Self-serve
This content is not available in your language yet.
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
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.
Ferramentas do MCP Operacional (esboço)
Seção intitulada “Ferramentas do MCP Operacional (esboço)”| Tool | O que faz | Nível |
|---|---|---|
provision_association(nome, slug, plano) | Cria tenant isolado + namespace de streams + plano de acesso | Operador |
seed_admin_user(associação, email, senha_temporária) | Cria o primeiro usuário admin com flag must_change_credentials | Operador |
list_associations() | Lista todos os tenants ativos com status e plano | Operador (leitura) |
suspend_association(id) | Pausa acesso ao tenant (mantém dados) | Operador |
reset_credential(user) | Força novo must_change_credentials num usuário existente | Operador |
Exemplo concreto
Seção intitulada “Exemplo concreto”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.
Sequência de provisionamento
Seção intitulada “Sequência de provisionamento”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"
Primeiro acesso: onboarding no agente
Seção intitulada “Primeiro acesso: onboarding no agente”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.
Sequência de primeiro acesso
Seção intitulada “Sequência de primeiro acesso”- Agente detecta
must_change_credentials— lê o claim no JWT logo após a autenticação inicial com senha temporária. - 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). set_initial_credentialsexecutado — 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 retorno é o convite — a última milha
Seção intitulada “O retorno é o convite — a última milha”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.
Exemplo: convite gerado pelo MCP Operacional
Seção intitulada “Exemplo: convite gerado pelo MCP Operacional”MCP Operacional · Resultado da ferramenta
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)
Entrar: https://casa-da-mata-sv.cannabr.org
No 1º login você define a senha real + ativa 2FA. A credencial temporária expira em 7 dias.
{
"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.
| 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 |
Pattern: o retorno é o artefato acionável
Seção intitulada “Pattern: o retorno é o artefato acionável”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.
Camada de Aplicação
Seção intitulada “Camada de Aplicação”O domínio precisa ser legível para humano e para o agente. Use cases semânticos = linguagem ubíqua que os dois entendem.
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 catálogo abaixo — cada use case, o que faz, e onde é exposto.
provisionAssociation Cria uma nova associação (tenant) isolada na instância. seedAdminUser Semeia o primeiro admin com credencial temporária (troca obrigatória). listAssociations Lista os tenants ativos com status e plano. authenticate Autentica o usuário e emite o JWT com tenant + papel. via Zitadel (OIDC) — claims tenant+papel no JWT. setInitialCredentials Troca a credencial temporária pelo acesso real no 1º login. resetCredential Força novo must_change_credentials num usuário existente. registerMember Cadastra um novo associado no tenant (identidade: nome, CPF, contato, papel). getMember Carrega os dados de identidade de um associado do tenant. listMembers Lista os membros do tenant por status (ciclo de vida). viewMemberQuota Visualiza a cota do membro (somente leitura — a escrita da cota entra na v0.2). validatePrescription v0.2 · Valida a prescrição e escreve a cota mensal do membro. grantConsent v0.2 · Registra o consentimento LGPD do associado. revokeConsent v0.2 · Revoga o consentimento (gatilho de anonimização). anonymizeMember v0.2 · Apaga dados pessoais via crypto-deletion (LGPD Art. 18). createLot v0.2 · Cria um lote em quarentena (Inventory). releaseLot v0.2 · Libera um lote aprovado para dispensação (Inventory). recallLot v0.2 · Recolhe um lote (contaminação / qualidade) (Inventory). recordDispensation v0.2 · Registra dispensação (dispensa + cota + lote) (Dispensation). submitSngpc v0.2 · Submete o relatório SNGPC à ANVISA (consumidor assíncrono via NATS). suspendMember v0.2 · Suspende temporariamente um associado (bloqueia dispensações sem excluir dados). reinstateMember v0.2 · Reintegra um associado suspenso, restaurando acesso às dispensações. quarantineLot v0.2 · Coloca um lote em quarentena preventiva pendente de análise de qualidade. loadLotState v0.2 · Carrega o estado atual de um lote (quarentena, liberado, recolhido, esgotado). loadAssociationDispensations v0.2 · Lista todas as dispensações da associação com filtros de período e membro.
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.
A fatia usável
Seção intitulada “A fatia usável”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.
Self-hosting & multi-tenant (o modelo)
Seção intitulada “Self-hosting & multi-tenant (o modelo)”A pergunta que v0.1.0 responde: como se vende e se entrega isso?
| Modo | Quem | Receita | v0.1.0 entrega |
|---|---|---|---|
| Gerenciado (multi-tenant) | Plataforma hospeda N associações numa instância | Infra = receita (tese infraeconomics) | Login multi-tenant + isolamento por tenant + onboarding |
| Self-host (OSS) | Federação/associação roda a própria instância | Comunidade / licença comercial p/ embed | Mesmo 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.
Arquitetura inicial (v0.1.0)
Seção intitulada “Arquitetura inicial (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).
Por que Zitadel
Seção intitulada “Por que Zitadel”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.
Por que SurrealDB + NATS JetStream
Seção intitulada “Por que SurrealDB + NATS JetStream”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.
C4 — v0.1.0
Seção intitulada “C4 — v0.1.0”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 SurrealDB — casa-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.
Escopo & Done-when
Seção intitulada “Escopo & Done-when”Dentro de v0.1.0:
| Capability | Valor | Done-when |
|---|---|---|
| Onboarding de associação | Cria o tenant + primeiro admin | Form/fluxo que provisiona uma associação nova e seu usuário admin |
| Login multi-tenant | Porta de entrada isolada por tenant | Página de login → JWT com { tenant, role }; TOTP opcional |
| Isolamento por tenant | Dado de uma associação invisível pra outra | Streams + read-models namespaced; teste cross-tenant nega acesso |
| Operações básicas | Algo pra fazer logo de cara | Cadastrar membro + ver painel escopado ao tenant |
| Deploy documentado (2 modos) | Vendável + self-hostável | Docker 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.