Lição 01 · Alembic Completo v3 · Fundação: Contratos e ETL ← índice
Alembic Completo v3 · Módulo 1 · O motor

Fundação: Contratos e ETL

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.

1

A grande ideia: falhar fechado é um TIPO

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.

fusao · 05-ports-and-injection · s0
packages/contracts/src/result.ts:10–26 (trecho real)
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 ResulttryCatchAsync 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).

2

Tier: a escada de autonomia

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.

TierSignificadoQuem vigia
T0Silencioso, determinístico, $0Ninguém (é o substrato)
T1Autônomo, logging leveLog
T2Autônomo, revisor notificado1 revisor
T3Autônomo, council obrigatórioCouncil
T4PARK — 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.

3

MODEL_REGISTRY e o leilão do tier

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.

O tie-break que virou PR
O reduce usa < 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.
pickCheapestForTier(T2) — registry.ts:244 gemini-3.5-flash T2 · 0.0005/1k · 1º deepseek-v4-pro T2 · 0.0005/1k · empate glm-5.2 T2 · 0.0008/1k qwen3.7-max · 0.00095 filter(tier===T2) reduce(custo < ) estrito: empate NÃO troca vence: gemini-3.5-flash 1º declarado no empate (PR #157)
O leilão do T2: filtra por tier, reduz por custo com < estrito — no empate de 0.0005, o primeiro declarado no MODEL_REGISTRY vence (registry.ts:252–256).
Zod na borda é a outra metade dos contracts: 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.
4

ETL: FsPort e o T0 determinístico

@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.

alembic-completo · 0001-camada-l0-contracts-etl · s0
corpus/*.jsonl read-only walkCorpus stream, s/ buffer exclui Repos/Models e Repos/Prompts sha256Hex dedupe p/ hash _alembic-processed.jsonl ledger append-only valida contrato + score L0 scored fica no T0 residue p/ T1–T3 desfechos por linha: scored · residue · blocked · duplicate · invalid (pipeline.ts:44–49)
O T0 de pipeline.ts: caminhada em stream (nunca bufferiza a árvore), dedupe por SHA-256 contra o ledger, validação e roteamento — residue vai para _alembic-residue.jsonl (pipeline.ts:41).

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

Priors por família: roteamento antes de qualquer modelo

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.

5

A gotcha do classifyFamily

Gotcha que custou caro (06–07) 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 T0routesToResidue = 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).
packages/etl/src/priors.ts:70–76 (trecho real)
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.

6

Cheque seu modelo mental

Dois modelos T2 empatam em 0.0005/1k no MODEL_REGISTRY. Quem vence o leilão de pickCheapestForTier?
Correto: 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.
Você roda alembic distill ~/Documents/Resources/Bookmarks --offline — apontando para a RAIZ da família. O que acontece?
Correto: 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.
Camada técnica — comandos copy-paste

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/.

Pergunta de acompanhamento sugerida: "por que o Alembic prefere um 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.