Tudo no Alembic — 27 packages + 1 app = 28 workspaces, 1.834 testes verdes — se apoia em duas peças: @alembic/contracts (o vocabulário: Result, Tier, registry, schemas Zod) e @alembic/etl (o chão de fábrica: FsPort, o T0 determinístico de custo $0). Nesta lição você aprende as duas — e a gotcha de família que custou uma noite de residue 0.
Em biblioteca do Alembic, erro não é exceção voando pela pilha — é um valor. O Result<T, E> de packages/contracts/src/result.ts é uma união discriminada em ok: ou Ok<T> com value, ou Err<E> com error (result.ts:10–20). Quem chama é OBRIGADO pelo compilador a olhar o ramo de falha antes de tocar no valor. É isso que "fail-closed" significa na prática: o caminho de erro não é opcional, esquecível ou silencioso.
Pense como… um disjuntor: quando algo dá errado, o circuito abre e NADA passa — em vez de deixar a corrente (dado inválido) seguir e queimar a casa. Onde a analogia quebra: um disjuntor você rearma no escuro; um err carrega a causa dentro dele.
export interface Ok<T> { readonly ok: true; readonly value: T; } export interface Err<E> { readonly ok: false; readonly error: E; } export type Result<T, E = Error> = Ok<T> | Err<E>; export const ok = <T>(value: T): Ok<T> => ({ ok: true, value }); export const err = <E>(error: E): Err<E> => ({ ok: false, error });
Os utilitários tryCatch e tryCatchAsync (result.ts:46–69) embrulham código que lança e devolvem Result — tryCatchAsync nunca rejeita. Para chamadas de modelo existe o irmão mais rico, ModelRunResult (lição 02); os dois leem igual no call site porque ambos discriminam em ok (result.ts:5–8).
packages/contracts/src/tier.ts:15–22 define a escada T0…T4: T0 silencioso/autônomo, T1–T3 autônomos com escrutínio crescente (isAutonomous, tier.ts:59–60), e T4 = PARK — retido até council + humano. O detalhe de projeto que revela a filosofia: DEFAULT_TIER = Tier.T4 (tier.ts:51). Trabalho não classificado nasce estacionado, não executado. O default é a trava, não a permissão.
| Tier | Significado | Quem vigia |
|---|---|---|
| T0 | Silencioso, determinístico, $0 | Ninguém (é o substrato) |
| T1 | Autônomo, logging leve | Log |
| T2 | Autônomo, revisor notificado | 1 revisor |
| T3 | Autônomo, council obrigatório | Council |
| T4 | PARK — retido (o DEFAULT) | Council + humano |
Ortogonal à escada existe o marcador LOCAL (tier.ts:36–37): trabalho pregado em modelo local/$0 independente do tier — o caminho de privacidade e de custo zero.
O catálogo de modelos vive em packages/contracts/src/registry.ts:57–230. Cada entrada declara modelId, adapterId (em geral cliproxyapi, ou local para a pista $0), tier e preço por 1k tokens — tudo validado por modelRegistryEntrySchema (Zod, registry.ts:8–30). Quando ninguém pina um modelId, pickCheapestForTier(tier) (registry.ts:244–257) faz um leilão: filtra o tier e reduz pelo custo combinado input+output.
< estrito (registry.ts:255): num empate de custo, vence quem foi declarado primeiro no objeto — comportamento documentado no próprio registry (registry.ts:82–84: "pickCheapestForTier keeps the FIRST entry on cost ties"). Foi exatamente isso que o PR #157 explorou: gemini-3.5-flash e deepseek-v4-pro empatam em T2 a 0.0005/1k (0.0001 + 0.0004 cada, registry.ts:150–160 e 175–185); como o gateway negava acesso ao deepseek (AccessDenied), a correção foi declarar o gemini antes — e o leilão passou a resolver para um modelo que a conta consegue usar. Ordem de declaração é semântica de roteamento. Trate o registry como código, não como lista.modelRegistryEntrySchema, tierSchema (tier.ts:25–31) e todos os schemas do pacote validam dados na fronteira (CLI, arquivos, respostas de modelo) — dentro do sistema circulam apenas tipos já provados. É o mesmo idioma que você verá no CLI (lição 05): parse na entrada, tipos puros no meio.@alembic/etl é a camada L0: quem caminha o corpus, deduplica, valida e roteia — a $0, sem modelo nenhum. Toda a E/S passa por uma porta injetável, o FsPort (packages/etl/src/fs-port.ts:61–83), com dois invariantes gravados no doc do arquivo (fs-port.ts:19–24): leituras do corpus são READ-ONLY; saídas são APPEND-ONLY (só appendLine escreve, mais ensureDir uma vez). Um writeFileAtomic (tmp + rename, fs-port.ts:172–176) cobre os stores que precisam de troca atômica — um leitor concorrente nunca vê arquivo pela metade.
Peças, uma a uma: walkCorpus (pipeline.ts:114–120) percorre em profundidade sem bufferizar e não desce em Repos/Models/Repos/Prompts (priors.ts:34–37); sha256Hex (dedupe.ts:38–39) é a chave de conteúdo — conteúdo idêntico em qualquer lugar colapsa em um; o ledger _alembic-processed.jsonl é só-append (dedupe.ts:14–17), então re-rodar um corpus inalterado é no-op; e cada linha termina num de cinco desfechos tipados (pipeline.ts:44–49).
Cada família de fonte carrega um prior barato e determinístico (priors.ts:54–64): Transcripts→T2 (0.9), Discord/Circle→T2, Skool→T2, Whatsapp/Bookmarks/Skills→T1, Repos→T1 — e Unknown→T0 (0.1). Item cuja família mira acima do piso T0 vira residue para os tiers pagos; o resto o T0 resolve sozinho, de graça.
classifyFamily decide a família pelo 1º segmento do path RELATIVO ao corpus (priors.ts:70–76). Aponte o distill para o PAI de Resources (ex.: ~/Documents/Resources) e o 1º segmento é Bookmarks → prior T1 → residue flui. Aponte para a RAIZ da família (…/Resources/Bookmarks) e o 1º segmento vira a pasta interna (items, index…) → Unknown → prior T0 → routesToResidue = false → residue 0, silenciosamente. Nenhum erro, nenhum aviso: o funil "funciona" e não produz nada. Foi este exato modo de falha que atrasou a primeira produção da rota de learnings — que, apontada certa, gerou 1.022 learnings de Bookmarks em 06-07 (a primeira vez na história do motor).export const classifyFamily = (relativePath: string): SourceFamily => { const head = relativePath.split(/[\\/]/).find((seg) => seg.length > 0) ?? ''; const match = SOURCE_FAMILIES.find( (family) => family !== 'Unknown' && family === head, ); return match ?? 'Unknown'; };
Regra prática: distill sempre no pai das famílias. Para escopar UMA família só, a técnica provada é materializar apenas os package.jsonl daquela família numa árvore-espelho (o truque do cpio) — o path relativo continua começando com o nome da família.
pickCheapestForTier?registry.ts:252–256 reduz com entryCost < bestCost — estrito. Empate mantém o acumulado, ou seja, o PRIMEIRO declarado. O PR #157 usou isso: declarou gemini-3.5-flash antes do deepseek-v4-pro (AccessDenied no gateway) para o empate de 0.0005 resolver num modelo utilizável.alembic distill ~/Documents/Resources/Bookmarks --offline — apontando para a RAIZ da família. O que acontece?classifyFamily (priors.ts:70–76) olha o 1º segmento RELATIVO — que aqui é items/index, não Bookmarks. Unknown tem prior T0 (priors.ts:63), nada roteia para residue, e não há erro nenhum. Aponte no PAI de Resources.Veja o leilão e os contratos com seus próprios olhos (só leitura, $0):
# o registry inteiro + o tie-break documentado sed -n '57,120p' packages/contracts/src/registry.ts grep -n "FIRST entry on cost ties" packages/contracts/src/registry.ts # a escada de autonomia e o default parkeado grep -n "DEFAULT_TIER" packages/contracts/src/tier.ts # a gotcha de família, na fonte sed -n '70,76p' packages/etl/src/priors.ts # um distill hermético $0 (aponte no PAI das famílias!) alembic distill ~/Documents/Resources --offline --json # baseline do repo (sempre via safe-test — órfãos tinypool são reais) pnpm -r typecheck && pnpm -r build && node scripts/safe-test.mjs pnpm -w test
Números da casa: 2.589 signals no opportunity-graph, 1.022 learnings únicos (100% com sourceRefs+hash, confiança média 0.94), 19 ADRs em docs/adr/.
Result a lançar exceção mesmo dentro de código interno?" — Na próxima lição, a resposta vira arquitetura: a cintura estreita ModelAdapter.run, que nunca lança, e o funil T0→T3 com DOIS orçamentos independentes.