Synapse: Crítica Profissional e Decisões de Arquitetura
Documento de revisão dos arquivos
01-data-schema.mde02-rust-architecture.md. Faz o papel de “ADR-zero”: consolida o que foi questionado, o que muda, o que fica.
Data: 2026-05-24 Status: Aprovado para iniciar implementação Revisor: consultoria Claude (Opus 4.7)
1. Veredito em uma página
| Camada | Veredito | Ação |
|---|---|---|
| Ideia | Sólida, foco correto, gargalo real bem nomeado. Risco principal: virar “Obsidian-com-CLI-extra” se a porta MCP não for o eixo. | Manter MCP como ferramenta de primeira classe, não como exportador. Cada feature de CLI tem de ter contraparte MCP. |
| Schema de dados | Bem desenhado conceitualmente; vazamentos: integridade referencial difusa, ausência de versionamento e de tipo “decision/note” genérico, roles mistura papel social e organizacional. | Endurecer (ver §3): chaves estrangeiras explícitas, tipo note genérico, separar role (RBAC do projeto) de relationship (papel social). |
| Arquitetura Rust | Borda → núcleo → dados está correto; mas o Context global é um anti-padrão, commands/mod.rs vira god-file, e i18n no error.rs mistura camadas. | Refatorar para Application + traits explícitas (ver §4). Manter clap v4: alternativas não justificam o custo. |
| Stack | Versões não especificadas no doc original. | Adotar tabela do §5 (Rust 1.85+ edição 2024, rmcp 1.7, rusqlite 0.39 bundled, serde_yaml_ng: nunca serde_yaml nem serde_yml). |
| Documentação | Não existe convenção estabelecida. | Adotar Rust API Guidelines: //! em módulos, /// em itens, # Examples/# Errors/# Panics, #![warn(missing_docs)] no lib.rs, cargo doc -D warnings na CI. |
| Git/commits | Não definido. | Conventional Commits estilo Soma/Eved/Auth (type(SCOPE): subject), core.hooksPath = .githooks/ com scripts shell: sem husky/pre-commit (Node/Python). |
2. Crítica da ideia
2.1 O que está certo
A dor está bem nomeada: o gargalo do contexto que evapora é real e tende a piorar à medida que a IA derruba o gargalo da execução. A escolha por texto como fonte da verdade com SQLite como índice descartável é tecnicamente conservadora no melhor sentido: você não acopla a vida do conhecimento à vida da ferramenta. E iniciar por CLI + MCP, deixando UI para depois, é a decisão correta: UI sem base de conhecimento é uma tela bonita vazia.
2.2 O que precisa de atenção
O posicionamento “segundo cérebro” é largo demais. Notion, Obsidian, Logseq, mem.ai, Capacities, Reflect, Tana, Anytype: todos se vendem como “segundo cérebro”. A diferenciação real e defensável do Synapse não é “outro PKM”: é PKM operacional para quem orquestra IA. A versão atual do README enfatiza isso só ao final (“a IA finalmente consegue consultar também”). Esse é o lead, não o desfecho. Sem essa nitidez, o produto vira “Obsidian sem GUI”.
O MCP precisa ser ortogonal, não apêndice. O atual commands/mcp.rs em §02 sugere que o MCP é “mais um comando”. Não é: é a segunda interface igualmente importante. Toda função de leitura em commands/ precisa ter contrapartida em mcp/tools.rs, e a arquitetura tem de tornar essa duplicação inviável (compartilhar a função abaixo, nunca reimplementar). Senão você acumula débito: feature nova no CLI, MCP não vê: exatamente a dor original do produto.
A IA escreve, não só lê. O escopo MVP fala só de leitura (search_knowledge, get_project, find_person). Mas o ganho real chega quando a IA também registra: “vi que você mencionou X em chat, criei tasks/alfa-053.md”. Sem ferramentas de escrita no MCP (com confirmação humana), o Synapse trata a IA como leitor: o que limita seu valor a metade.
Risco de vendor-lock no MCP. MCP é Anthropic. Apostar nele 100% é uma aposta no Claude Code dominar. Mitigar: o mcp/ é uma borda: manter o núcleo index::query portável o suficiente para expor por HTTP/JSON-RPC simples caso surja um padrão concorrente (Gemini Computer Use, OpenAI Apps SDK). Custo: nenhum: basta não importar rmcp no núcleo.
3. Crítica do schema (01-data-schema.md)
3.1 Integridade referencial é decorativa
O schema diz “vínculos por referência nunca por cópia” e mostra roles: [{project: projeto-alfa, role: ponto-focal}] em pessoa. Mas nada no banco impede que projeto-alfa não exista (syn doctor detecta depois: tarde). Em SQLite com FOREIGN KEY ativo, isso é uma checagem barata e síncrona.
Decisão:
- Habilitar
PRAGMA foreign_keys = ONem toda conexão (rusqlite não habilita por default). - Em
links, adicionarFOREIGN KEY (from_id) REFERENCES entities(id) ON DELETE CASCADEe o mesmo parato_id. - Para o tipo
task, adicionarFOREIGN KEY (project) REFERENCES entities(id): mesmo ofrontmattersendo a fonte da verdade, o índice deve refletir erros imediatamente, não após reindex.
3.2 Ausência do tipo note genérico
O schema tem project, person, entity, api, task: todas categorias de “atores ou demandas”. Não há onde guardar: uma decisão (“decidimos não usar Stripe por X”), uma runbook (“como rotacionar o secret do webhook”), uma transcrição de meeting, um snippet de comando útil. Tudo isso é exatamente o tipo de informação que escapa hoje.
Decisão: adicionar note como tipo genérico.
---
type: note
id: decisao-pagamentos-stripe
title: Por que não escolhemos Stripe
kind: decision # decision | runbook | meeting | snippet | reference
project: projeto-alfa # opcional
tags: [pagamentos, decisao-arquitetural]
created: 2026-05-24
updated: 2026-05-24
---
Sem isso, o usuário cria entities/decisao-stripe.md (sobrecarrega entity) ou um tasks/ falso. Ambos são erros de design que pagamos depois.
3.3 roles mistura RBAC com papel social
roles: [{project, role}] em person carrega coisas heterogêneas: ponto-focal, aprovadora, revisora, dev, pm. Algumas são organizacionais (cargo na empresa), outras são funcionais (papel naquele projeto).
Decisão: renomear para memberships (papel funcional naquele projeto, livre) e adicionar title no nível de person (cargo formal: “Tech Lead”, “PM”). Pequena mudança, semântica ganha. role é palavra com peso em sistemas auth (Febasi Auth API usa exatamente esse termo): confusão evitável.
3.4 Falta versionamento e auditoria leve
Todo arquivo tem created/updated. Mas e quando você quer saber: quem era ponto-focal do Alfa em fevereiro? O git resolve, sim, mas a busca por “estado em data X” via git log é cara e quebra o modelo “consulte via SQL”.
Decisão para v1: confiar no git (não inventar coluna de versão). Mas indexar a data do último commit que tocou o arquivo na tabela entities (coluna last_modified_commit) e expor --at <date> no syn search futuramente. O custo agora é zero (não implementar); o custo de não desenhar agora é alto (mudar schema depois). Documentar como futuro, não como pendente.
3.5 IDs duplicados entre tipos
Um project chamado pagamentos, uma api chamada pagamentos, uma entity chamada pagamentos: o esquema permite. A coluna PRIMARY KEY é id, sem type. Vai pegar fogo.
Decisão: PRIMARY KEY composta (type, id) em entities, ou prefixar id internamente (person:maria-silva, api:pagamentos) no índice: slug puro vira stable_ref (nullable, único por type). A primeira opção é mais simples; a segunda mais ergonômica em queries. Vou pela primeira por consistência com como o usuário pensa o arquivo (apis/pagamentos.md ≠ entities/pagamentos.md).
3.6 Frontmatter armazenado como JSON é OK, mas custa portabilidade de filtro
frontmatter TEXT NOT NULL em JSON com json_extract() funciona, mas filtros tornam-se invisíveis a quem só lê o schema. Promover colunas explícitas para os campos consultáveis quentes (status, owner, priority, due), não todos. JSON fica como fallback para campos raros. Custo: zero migration agora; ganho: índices reais e queries legíveis.
3.7 sqlite-vec é alpha (0.1.10-alpha.4)
O documento promete “fase 2: busca semântica (sqlite-vec)” sem flag. Risco: breaking changes garantidos. Decisão:
- Manter o plano, mas isolar atrás de feature flag (
--features vector) e em migration separada (migrations/vector/*.sql), nunca na migration principal. - Não emitir embeddings na fase 1. Sequer instalar o crate. Documentar como roadmap.
4. Crítica da arquitetura Rust (02-rust-architecture.md)
4.1 Context é um Service Locator disfarçado
#![allow(unused)]
fn main() {
pub struct Context {
pub config: Config,
pub loader: FluentLanguageLoader,
pub format: OutputFormat,
pub verbose: bool,
pub workspace: Option<Workspace>,
}
}
Passar &Context para todo comando é cômodo, mas é o padrão Service Locator: você só descobre as dependências reais de um comando lendo o corpo dele. Em Rust, isso compila ótimo e testa mal: para mockar o workspace em um teste, você monta o Context inteiro.
Decisão: quebrar em AppDeps (composição) com submódulos injetáveis: Db, Workspace, I18n, Clock. Cada comando aceita só o que usa:
#![allow(unused)]
fn main() {
pub trait CommandRun {
fn run(self, deps: &AppDeps) -> Result<Output>;
}
}
E para teste:
#![allow(unused)]
fn main() {
let deps = AppDeps::builder()
.db(in_memory_db())
.clock(FakeClock::fixed("2026-05-24"))
.build();
}
O Context original vira o AppDeps; o ganho é que cada teste só popula o que importa.
4.2 commands/mod.rs vira god-file de match
#![allow(unused)]
fn main() {
match cmd {
Commands::Setup => setup::run(ctx),
Commands::Project(a) => project::run(a, ctx),
// ... 12 variants ...
}
}
Em 6 meses esse arquivo tem 200 linhas e cada PR que adiciona comando edita ele. Decisão: o enum Commands do cli.rs implementa diretamente um trait Dispatch:
#![allow(unused)]
fn main() {
impl Dispatch for Commands {
fn dispatch(self, deps: &AppDeps) -> Result<Output> {
match self {
Self::Setup(a) => a.run(deps),
Self::Project(a) => a.run(deps),
// delegação direta, não rebound de função
}
}
}
}
Cada *Args implementa CommandRun. O main.rs só chama cli.command.dispatch(&deps). Adicionar comando = adicionar variant + impl, zero edição em mod.rs.
4.3 Erros com chave i18n no thiserror é ruim de ler
#![allow(unused)]
fn main() {
#[error("error-no-workspace")]
NoWorkspace,
}
O #[error] perde sua função (mensagem default em Debug/Display). Quem lê stack trace vê error-no-workspace em vez do que é. E o format!/painic em release vai vazar a chave.
Decisão: manter #[error("...")] com mensagem em inglês curto e técnico (default sem i18n) e adicionar método separado fn fluent_key(&self) -> &'static str no impl Error:
#![allow(unused)]
fn main() {
#[derive(Error, Debug)]
pub enum Error {
#[error("workspace not found")]
NoWorkspace,
#[error("entity {id} already exists")]
EntityExists { id: String },
}
impl Error {
pub fn fluent_key(&self) -> &'static str {
match self {
Self::NoWorkspace => "error-no-workspace",
Self::EntityExists { .. } => "error-entity-exists",
// ...
}
}
pub fn fluent_args(&self) -> Vec<(&'static str, FluentValue<'_>)> { ... }
}
}
Debug/logs ficam legíveis em inglês; UI fica traduzida. Separa identidade (variant) de redação (chave) de fallback técnico (string #[error]).
4.4 Camadas faltando: núcleo está acoplado a borda
O documento original diz “borda → núcleo → dados” mas o commands/project.rs faz tudo: parse args, valida, escreve no disco, atualiza índice, formata saída. Sem use cases puros, testar a regra “criar projeto não permite duplicar slug” exige montar workspace em disco.
Decisão: introduzir core/ (ou app/) entre commands e store:
src/
├── cli.rs # clap (borda)
├── commands/ # ↓ chama core, traduz erros
├── core/ # USE CASES PUROS (sem io::, sem fs::, sem clap)
│ ├── create_project.rs
│ ├── link_person.rs
│ └── search.rs
├── store/ # filesystem
├── index/ # sqlite
└── mcp/ # ↓ chama core também
Tanto commands/ quanto mcp/ chamam a mesma função em core/. Essa é a única forma de garantir “não há duas verdades” entre CLI e MCP: o doc original promete isso mas o layout permite divergência.
4.5 Falta Workspace ter responsabilidade de transação
syn project add faz: cria pasta → escreve _meta.md → atualiza índice. Se o índice falha, o arquivo já está lá. Quando o syn reindex rodar, fica órbita inconsistente.
Decisão: Workspace expõe tx<R>(f: impl FnOnce(&mut Tx) -> Result<R>) que escreve em pasta temporária + rename atômico no commit, e abre transação SQLite simultânea. Se qualquer um falhar, rollback dos dois. Não é “implementar mais tarde”: é uma decisão de design que afeta a assinatura do Store.
4.6 MCP sobre stdio com tokio no binário CLI custa a vida cara
Se o tokio for puxado por causa do MCP, ele entra na rota do CLI mesmo quando você só roda syn search foo. Isso atrasa cold-start (centenas de ms para inicializar o runtime).
Decisão: feature flag mcp (default-on para o release, mas opcional). O comando syn mcp puxa tokio via #[cfg(feature = "mcp")]. O resto do binário roda síncrono (rusqlite é síncrono mesmo). Para o release principal, dois binários: syn (sem tokio) e syn-mcp (com tokio), ou um só com lazy runtime init dentro do comando mcp. Lazy init é o caminho mais simples e suficiente.
4.7 clap vs alternativas: manter clap
Comparativo (maio 2026):
| Crate | Binário | Build | UX | Veredito |
|---|---|---|---|---|
| clap v4 | ~574 KiB | ~3 s | env vars, completions (5 shells), mangen, derive maduro | usar |
| bpaf 0.9 | ~256 KiB | ~830 ms | combinator + derive, completion dinâmica, sem mangen, ecossistema menor | considerar em v2 se compile time doer |
| argh | ~38 KiB | ~4 s | last release 2023, 63 issues abertas | evitar |
| lexopt | ~37 KiB | ~330 ms | hand-written, zero ergonomia pronta | evitar |
| pico-args | ~24 KiB | ~300 ms | help fraco, frágil | evitar |
O delta de 500 KiB do clap é irrelevante perto do tokio + rmcp + i18n-embed + fluent (~3-6 MiB). O ganho de UX (completions, env, mangen, suggestions on typo) é grande. Decisão: clap v4 com features enxutas (ver Cargo.toml em §5).
4.8 build.rs com git rev-parse quebra build sem .git
O build.rs do doc roda git rev-parse. Em release que vier de cargo install synapse (sem repo), isso é silencioso (.ok()), mas perde a info. Em build dentro de Docker layered sem .git, idem.
Decisão: usar vergen-gix (não vergen-git clássico: depende do git CLI) que extrai do .git/ direto via libgit2 e emite as envs. Cai bem em CI e local sem dep externa. Fallback gracioso quando não há .git.
5. Stack definitiva (maio 2026)
Cargo.toml de partida (todas as versões verificadas em lib.rs/crates.io em 2026-05-24):
[package]
name = "synapse"
version = "0.1.0"
edition = "2024"
rust-version = "1.85" # mínimo p/ edição 2024; igual ao ripgrep
license = "MIT"
authors = ["Rômulo Febasi <romulo@febasi.com.br>"]
description = "Second-brain CLI + MCP server for AI-augmented developers"
repository = "https://github.com/romulofebasi/synapse"
keywords = ["cli", "mcp", "knowledge-base", "second-brain", "claude"]
categories = ["command-line-utilities", "database"]
[dependencies]
# --- CLI ---
clap = { version = "4.6", features = ["derive", "wrap_help", "env", "string"] }
clap_complete = "4.6"
# --- Errors ---
anyhow = "1.0"
thiserror = "2.0"
# --- Serde stack ---
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde_yaml_ng = "0.10" # NÃO use serde_yaml (archived) nem serde_yml (RUSTSEC-2025-0068)
toml = "1.1"
# --- Storage ---
rusqlite = { version = "0.39", features = [
"bundled", # libsqlite embutida: elimina classe de bugs "qual sqlite o user tem"
"modern_sqlite",
"blob",
"backup",
"functions",
"load_extension", # necessário p/ sqlite-vec (fase 2)
] }
# --- Markdown + frontmatter ---
pulldown-cmark = { version = "0.13", default-features = false, features = ["html"] }
gray_matter = "0.3"
# --- Paths / fs ---
etcetera = "0.11" # XDG-aware, edição 2024; melhor que `directories` para CLI
notify = "8" # 9.0 está em RC; manter linha estável
# --- i18n (Fluent) ---
i18n-embed = { version = "0.16", features = ["fluent-system", "desktop-requester"] }
i18n-embed-fl = "0.10"
fluent = "0.17"
sys-locale = "0.3"
rust-embed = "8"
# --- Logging ---
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
# --- Slug / unicode ---
slug = "0.1"
unicode-segmentation = "1.13"
# --- MCP (gated) ---
rmcp = { version = "1.7", features = ["server", "transport-io", "macros"], optional = true }
tokio = { version = "1.52", features = ["rt-multi-thread", "macros", "fs", "signal", "io-util"], optional = true }
[features]
default = ["mcp"]
mcp = ["dep:rmcp", "dep:tokio"]
vector = [] # fase 2: sqlite-vec entra aqui
[build-dependencies]
clap = { version = "4.6", features = ["derive"] }
clap_complete = "4.6"
clap_mangen = "0.3"
vergen-gix = { version = "1.0", features = ["build", "cargo", "rustc", "si"] }
[dev-dependencies]
assert_cmd = "2.2"
predicates = "3.1"
tempfile = "3.27"
insta = { version = "1.47", features = ["yaml", "json"] }
rstest = "0.26"
[profile.release]
lto = "thin"
codegen-units = 1
strip = "symbols"
panic = "abort" # CLI: sem unwinding, menor binário
Tooling de projeto (não-deps):
| Ferramenta | Versão | Função |
|---|---|---|
cargo-nextest | 0.9 | test runner em CI (paralelismo melhor que cargo test) |
cargo-deny | 0.19 | licenses + advisories + bans + sources |
cargo-shear | 1.12 | detector de deps órfãs (parser-based) |
cargo-dist | 0.31 | release multi-plataforma |
committed | latest | linter Conventional Commits (Rust, sem Node) |
typos | latest | catador de typos em pre-commit |
6. Convenção de commits e hooks
Formato: Conventional Commits estilo Febasi.
type(SCOPE): subject
[optional body]
[optional footer]
type ∈ {feat, fix, docs, refactor, perf, test, chore, build, ci, revert}SCOPE ∈ {SYN-N, cli, mcp, index, store, model, i18n, config, deps, ci, hooks}(preferir ticket; senão área)subject: imperativo, minúsculo no início, sem ponto final, ≤72 char incluindo prefixo
Exemplos:
feat(SYN-1): bootstrap workspace skeleton
fix(index): respeitar PRAGMA foreign_keys em conexões reabertas
docs(adr): record decision to use rmcp 1.7 official sdk
chore(deps): bump rusqlite 0.38 → 0.39 (modern_sqlite default)
Hooks via core.hooksPath = .githooks/:
| Hook | Faz |
|---|---|
commit-msg | Valida formato Conventional via regex shell ou committed --commit-file $1 |
pre-commit | cargo fmt --check (rápido) + typos (catador de typos) |
pre-push | cargo clippy --all-targets -- -D warnings + cargo nextest run |
Por que não husky/lefthook/pre-commit? São deps de outro ecossistema (Node/Go/Python) em projeto Rust puro. core.hooksPath é nativo do git, zero install, funciona em CI igual local. Os hooks ficam versionados em .githooks/, ativados com git config core.hooksPath .githooks (rodado por scripts/bootstrap.sh).
7. Convenção de documentação (rustdoc)
Adotar Rust API Guidelines rigorosamente. Análogo ao JavaDoc/DartDoc, mas com a vantagem de que exemplos são compilados e testados:
Regras:
lib.rse cada arquivo de módulo começam com//!(doc de módulo) explicando: o que esse módulo faz, quando usar, um exemplo mínimo.- Cada item público (
fn,struct,enum,trait, campos públicos) tem///com:- Linha de resumo (uma frase, ponto final, infinitivo: “Computes …”, “Returns …”).
- Parágrafos de descrição se necessário.
- Seções padrão quando aplicáveis:
# Examples,# Errors,# Panics,# Safety.
- Intra-doc links sempre:
[`Workspace`],[`Db::open`]em vez de prosa. - Exemplos usam
?, nuncaunwrap(). - CI:
cargo doc --no-deps --all-features -D warnings. lib.rsdeclara:#![allow(unused)] #![warn(missing_docs)] #![warn(rustdoc::broken_intra_doc_links)] #![warn(rustdoc::private_intra_doc_links)] fn main() { }
Exemplo de doc de produção:
#![allow(unused)]
fn main() {
/// Cria um projeto novo no workspace e indexa o seu `_meta.md`.
///
/// O slug é derivado do `name` via [`crate::slug::slugify`] e deve ser único
/// dentro do workspace. Se já existir um projeto com o mesmo slug, retorna
/// [`Error::EntityExists`].
///
/// # Examples
///
/// ```
/// # use synapse::core::CreateProject;
/// # use synapse::test_deps;
/// let deps = test_deps();
/// CreateProject::new("Projeto Alfa").run(&deps)?;
/// # Ok::<_, synapse::Error>(())
/// ```
///
/// # Errors
///
/// - [`Error::EntityExists`] se o slug derivado já existe.
/// - [`Error::Io`] em falha de escrita do `_meta.md`.
/// - [`Error::Sqlite`] em falha na transação de índice.
pub fn run(self, deps: &AppDeps) -> Result<ProjectId, Error> { … }
}
8. Itens que ficam para depois (não-decisões)
| Item | Por que não agora |
|---|---|
sqlite-vec / embeddings | Crate alpha (0.1.x), breaking changes garantidas. Volta na fase 2 atrás de feature flag. |
| UI (TUI ou GUI) | Documento principal já diz: terceira porta, depois da base estar sólida. |
| Sync entre máquinas | Git já resolve. Documento de sync entra quando alguém pedir colaboração. |
| Plugins | YAGNI. Quando alguém precisar, abrir trait EntityType. |
Watcher (notify) | Reindex é milissegundos; watcher é otimização. Implementar quando alguém reclamar de re-rodar syn reindex. |
| LLM local p/ embeddings | Atrelado ao item de embeddings. |
Próximos artefatos: Cargo.toml, .githooks/, src/main.rs, src/lib.rs, .gitignore, rustfmt.toml, clippy.toml, deny.toml, README.md, AGENTS.md, CLAUDE.md, docs/adr/001-projeto-rust-edicao-2024.md, docs/adr/002-mcp-via-rmcp.md, docs/adr/003-sqlite-bundled-rusqlite.md, docs/adr/004-yaml-via-serde_yaml_ng.md.