Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Synapse: Crítica Profissional e Decisões de Arquitetura

Documento de revisão dos arquivos 01-data-schema.md e 02-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

CamadaVereditoAção
IdeiaSó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 dadosBem 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 RustBorda → 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.
StackVersõ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çãoNã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/commitsNã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 = ON em toda conexão (rusqlite não habilita por default).
  • Em links, adicionar FOREIGN KEY (from_id) REFERENCES entities(id) ON DELETE CASCADE e o mesmo para to_id.
  • Para o tipo task, adicionar FOREIGN KEY (project) REFERENCES entities(id): mesmo o frontmatter sendo 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):

CrateBinárioBuildUXVeredito
clap v4~574 KiB~3 senv vars, completions (5 shells), mangen, derive madurousar
bpaf 0.9~256 KiB~830 mscombinator + derive, completion dinâmica, sem mangen, ecossistema menorconsiderar em v2 se compile time doer
argh~38 KiB~4 slast release 2023, 63 issues abertasevitar
lexopt~37 KiB~330 mshand-written, zero ergonomia prontaevitar
pico-args~24 KiB~300 mshelp fraco, frágilevitar

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):

FerramentaVersãoFunção
cargo-nextest0.9test runner em CI (paralelismo melhor que cargo test)
cargo-deny0.19licenses + advisories + bans + sources
cargo-shear1.12detector de deps órfãs (parser-based)
cargo-dist0.31release multi-plataforma
committedlatestlinter Conventional Commits (Rust, sem Node)
typoslatestcatador 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/:

HookFaz
commit-msgValida formato Conventional via regex shell ou committed --commit-file $1
pre-commitcargo fmt --check (rápido) + typos (catador de typos)
pre-pushcargo 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:

  1. lib.rs e 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.
  2. 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.
  3. Intra-doc links sempre: [`Workspace`], [`Db::open`] em vez de prosa.
  4. Exemplos usam ?, nunca unwrap().
  5. CI: cargo doc --no-deps --all-features -D warnings.
  6. lib.rs declara:
    #![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)

ItemPor que não agora
sqlite-vec / embeddingsCrate 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áquinasGit já resolve. Documento de sync entra quando alguém pedir colaboração.
PluginsYAGNI. 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/ embeddingsAtrelado 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.