O e-mail chegou às 9:42 numa terça do time jurídico. Um regulador de mercado queria, por escrito, a resposta a uma pergunta sobre um cliente: por que essa reserva específica foi precificada desse jeito nessa data específica?
A reserva tinha onze meses. O time dono do rule set tinha rodado parcialmente. O arquivo de regra tinha sido editado quarenta e seis vezes desde então. Log de produção tinha rolado fora trinta dias antes. O dashboard mostrava um número pra aquele dia, mas não o caminho que produziu.
A gente respondeu o regulador. Levou dois engenheiros quatro dias. Puxamos o git log do arquivo de regra, reconstruímos o estado naquela data, fizemos replay da reserva contra o engine reconstruído, produzimos a explicação, e traduzimos num parágrafo que um não-engenheiro conseguia ler. A reserva real do cliente não era a que precisávamos defender; a categoria de decisão era a que importava, e em algum momento a gente defendeu.
O que a gente não tinha, no dia em que aquele e-mail chegou, era um sistema que respondesse a pergunta sozinho. Toda peça da resposta existia em algum lugar. Nenhuma das peças tava conectada. A explicação, o artefato que a gente vinha sinalizando ao longo dos últimos cinco posts, era a costura que faltava.
Esse post é como essa costura fica quando é construída de propósito.
As quatro audiências de uma explicação
O erro que cometi na primeira vez que construí explicabilidade num rule engine foi desenhar pra uma audiência só — o engenheiro debugando às 3 da manhã — e parar. Essa audiência é a mais barulhenta, mas é a que menos precisa de explicabilidade, porque o engenheiro lê o source.
Tem quatro audiências, e a explicação tem que servir todas sem desabar no menor denominador comum.
O engenheiro quer profundidade. Quais regras foram consideradas, quais dispararam, por que cada uma disparou ou não, qual foi o resultado de cada ação, onde o tempo foi gasto. O engenheiro lê JSON e fica tranquilo com nome de campo que não significa nada sem contexto.
O operador quer sinal. Qual snapshot do rule store serviu essa request? Tinha alguma regra desabilitada? A resposta foi normal ou anômala comparada com o tráfego vizinho? O operador não quer ler cada avaliação; quer saber se algo parece fora.
O dono de produto ou de domínio quer verificar o acordo. O sistema fez o que a gente disse que faria? As regras que escrevemos se comportaram como esperado? O dono de produto lê o nome da regra e o resultado; não lê chave de bucket.
O auditor ou regulador quer trilha de papel. Dado um cliente e uma data, que decisão foi tomada, por qual regra, com qual intenção, dona por qual time, com qual data de revisão? O auditor quer exatamente os campos chatos de metadado que o Post 1 defendeu não serem opcionais.
Cada audiência lê um subconjunto diferente do mesmo artefato. O trabalho da explicação é ser o mesmo artefato pra todas, com visões diferentes em cima. Um registro estruturado único, consultado de quatro jeitos diferentes.
O schema, por inteiro
O Post 5 esboçou a Explanation. Esse post preenche. O schema abaixo é em que cheguei depois de três iterações.
type Explanation struct {
// Identidade e rastreabilidade
CorrelationID string `json:"correlation_id"`
RequestID string `json:"request_id"`
SnapshotID string `json:"snapshot_id"` // hash do estado do engine
SchemaVersion int `json:"explanation_version"`
OccurredAt time.Time `json:"occurred_at"`
Duration time.Duration `json:"duration_ns"`
// O que entrou
Facts map[string]string `json:"facts"`
// O que aconteceu, estágio por estágio
CandidateSet []RuleRef `json:"candidates"` // output do matcher
Evaluations []EvaluationRecord `json:"evaluations"` // outcome por regra
Actions []ActionRecord `json:"actions"` // output por regra disparada
Composition CompositionRecord `json:"composition"` // política de resolução aplicada
// O que saiu
Result json.RawMessage `json:"result"`
// O que o operador deve se importar
Warnings []Warning `json:"warnings,omitempty"`
}
type EvaluationRecord struct {
Rule RuleRef `json:"rule"`
Outcome Outcome `json:"outcome"` // fired, failed_condition, disabled, errored
FailedAt string `json:"failed_at,omitempty"` // "when.days_to_departure.lt"
EvalDuration time.Duration `json:"eval_duration_ns"`
}
type ActionRecord struct {
Rule RuleRef `json:"rule"`
Output json.RawMessage `json:"output"`
Err string `json:"err,omitempty"`
Latency time.Duration `json:"latency_ns"`
}
type CompositionRecord struct {
Policy string `json:"policy"`
PerField map[string]CompositionTrace `json:"per_field"`
}
type CompositionTrace struct {
FinalValue json.RawMessage `json:"final_value"`
ContributingRules []string `json:"contributing_rules"`
Strategy string `json:"strategy"` // sum, last, first, fail
}
type RuleRef struct {
Name string `json:"name"`
Version string `json:"version"` // sha do git do arquivo, ou hash de rule_id
Owner string `json:"owner"`
Description string `json:"description"`
Priority int `json:"priority"`
Enabled bool `json:"enabled"`
}
type Warning struct {
Code string `json:"code"` // SHADOWED_RULE, EMPTY_CANDIDATE_SET, etc.
Message string `json:"message"`
Severity string `json:"severity"` // info, warn
}
Três propriedades desse schema que merecem viver em produção.
Todo registro aponta de volta pra fonte. RuleRef carrega Owner, Description e uma Version. O consumidor da explicação não precisa também carregar o arquivo de regra pra saber o que a regra significava. O campo Version é crítico: quando o arquivo de regra evolui, a explicação ainda referencia a regra como ela era no momento do Execute.
Latência mora por estágio. EvalDuration por avaliação, Latency por ação, e Duration pelo Execute inteiro. Tempo cumulativo total esconde onde o tempo foi gasto; latência por estágio torna o comportamento do engine observável sem profiler externo.
Warning é de primeira classe. Uma regra sombreada que disparou mesmo assim, um conjunto candidato suspeitosamente vazio, um passo de composição que bateu numa política fail — cada um vira um Warning que o dashboard do operador expõe. O dashboard não precisa parsear a explicação inteira; conta warning por código e plota a taxa.
Uma explicação real, ponta a ponta
É assim que uma Explanation fica pro cenário alemão de última hora que tô usando ao longo da série:
{
"correlation_id": "c1b9a4e7-21d8-4d0e-9a2a-1cb5a7e4f0b1",
"request_id": "req-2024-08-21T10:14:33Z-7b3f",
"snapshot_id": "sha256:7b3f...e91d",
"explanation_version": 2,
"occurred_at": "2024-08-21T10:14:33.012Z",
"duration_ns": 412330,
"facts": {
"market": "DE",
"channel": "rail",
"days_to_departure": "4",
"device": "mobile",
"regulated_market": "false"
},
"candidates": [
{"name": "compliance_markup_override", "version": "7b3f", "owner": "compliance",
"description": "0% markup em mercado regulado", "priority": 1000, "enabled": true},
{"name": "short_lead_time_markup_de", "version": "7b3f", "owner": "pricing-de",
"description": "3% markup em DE com menos de 7 dias", "priority": 500, "enabled": true},
{"name": "germany_baseline_markup", "version": "7b3f", "owner": "pricing-de",
"description": "2% markup baseline em toda DE", "priority": 100, "enabled": true}
],
"evaluations": [
{"rule": {"name": "compliance_markup_override"}, "outcome": "failed_condition",
"failed_at": "when.regulated_market.eq", "eval_duration_ns": 1850},
{"rule": {"name": "short_lead_time_markup_de"}, "outcome": "fired",
"eval_duration_ns": 4210},
{"rule": {"name": "germany_baseline_markup"}, "outcome": "fired",
"eval_duration_ns": 1320}
],
"actions": [
{"rule": {"name": "short_lead_time_markup_de"}, "output": {"markup_percentage": 3.0},
"latency_ns": 8120},
{"rule": {"name": "germany_baseline_markup"}, "output": {"markup_percentage": 2.0},
"latency_ns": 6210}
],
"composition": {
"policy": "additive_with_compliance_override",
"per_field": {
"markup_percentage": {
"final_value": 5.0,
"contributing_rules": ["short_lead_time_markup_de", "germany_baseline_markup"],
"strategy": "sum"
}
}
},
"result": {"markup_percentage": 5.0},
"warnings": []
}
O registro inteiro são 60 linhas de JSON pra um Execute. Carrega o bastante pro engenheiro, pro operador, pro dono de produto e pro auditor.
O engenheiro lê evaluations e actions e vê o caminho do engine.
O operador varre warnings e duration_ns e confirma que a request foi normal.
O dono de produto lê composition.per_field.markup_percentage.contributing_rules e confirma que o acordo segurou.
O auditor lê facts, candidates[*].owner e result e tem um parágrafo defensável.
Um artefato. Quatro visões. Sem passo de tradução.
O modelo de custo
O custo de uma Explanation é o custo que mais engenheiro teme e mais superestima.
Uma Explanation populada pra um engine de 100 regras tá na ordem de 5–15 KB de JSON, dominada pelo conjunto candidato e pelos registros de avaliação. A geração leva microssegundos — os registros já são produzidos pelo stack de listener do Post 5; emissão é json.Marshal sobre um struct tipado. O overhead do hot-path, quando a explicação é emitida, é um percentual de um dígito do tempo de Execute.
O custo que dói não é geração. É armazenamento. Em 10 000 QPS, explicação completa pesa 50 MB/s, 4 TB/dia, 120 TB/mês. Nenhum sistema retém isso indefinidamente.
Três estratégias, em sofisticação crescente, lidam com o custo.
Sampling. Um percentual fixo das requests recebe explicação completa; o resto não recebe nada. Útil pra visibilidade de engenharia no caminho médio. Inútil pra auditoria — a request que te perguntam é, pela lei de Murphy, sempre fora da amostra.
Emissão em camada. Toda request recebe uma explicação minúscula (5 linhas: snapshot ID, nome da regra disparada, resultado). Requests anômalas, requests sinalizadas por warning, requests de cliente de alto valor, e uma amostra de requests normais recebem explicação completa. O sinal é preservado; o custo é limitado.
Replay sob demanda. Toda request guarda o mínimo necessário pra reproduzir: snapshot ID, facts, correlation ID. A explicação completa é gerada de um replay contra o snapshot guardado quando perguntam. Isso funciona porque o engine é determinístico (a primeira propriedade do Post 1 se justifica aqui) e porque o ExportSnapshot / LoadSnapshot do bre-go faz do snapshot um artefato de primeira classe.
A arquitetura que eu acabo escolhendo na maioria das vezes é a terceira, sobreposta à segunda:
- Hot path emite explicação minúscula por request (snapshot ID, regras que dispararam, resultado) em amostra de 100%.
- Hot path emite explicação completa em amostra de 1%, mais sob demanda pra requests carregando uma flag
?debug=1do call site. - Cold path consegue regerar a explicação completa a partir de
(snapshot_id, facts, correlation_id)via replay, até o tempo de retenção do snapshot.
Retenção de snapshot é a restrição real de capacidade. Um snapshot é pequeno (arquivo binário, KBs a MBs). Manter todo snapshot que o engine já serviu é barato. Com o snapshot retido e os facts logados, a explicação completa é reconstruída a qualquer momento.
Emissão em camada: barato por padrão, completo sob demanda, reproduzível pra sempre
🔄 Rendering PlantUML diagram...
Trace ID como espinha da explicação
Explicação conecta com o resto do sistema pelo correlation ID. O engine.WithCorrelationID(ctx, id) do bre-go põe o ID no contexto; o engine lê do ConditionContext e ActionContext; a explicação carimba como primeiro campo.
O correlation ID é o que deixa um serviço atravessar trinta linhas de log lá embaixo e a explicação seguir sendo a mesma conversa. O engenheiro de suporte vê o trace ID no tooling dele; o rule engine sabe esse mesmo ID; a explicação é consultável por ele.
Três regras que tornam correlation ID útil na prática:
Gera na borda, propaga pra dentro. O gateway de API ou o call site gera o ID; o engine nunca inventa um. ID gerado pelo engine é um que mais nada consegue conectar.
Inclui em toda superfície de observabilidade. A métrica, a linha de log, a explicação, o registro do snapshot. Se o ID tá em três de quatro superfícies, o auditor ainda não consegue conectar.
Distingue correlation de request ID. Correlation é sessão — jornada do cliente, workflow, batch. Request é uma chamada. Vários Execute na mesma jornada do cliente compartilham um correlation ID; cada um tem o próprio request ID. A explicação carrega os dois.
O custo é uma string no schema. O benefício é que toda pergunta posterior — o que mais aconteceu nessa sessão do cliente? — tem resposta.
Log e explicação: quando cada um dispara
Um erro comum é despejar a explicação dentro de um structured log e chamar de observabilidade. É mais útil pensar log e explicação como dois artefatos diferentes com dois tempos de vida diferentes.
Log é evento time-series. Vai pro agregador de log. É sampleado, derrubado sob carga, e rolado fora depois de semanas. Bom pra “o que aconteceu na última hora?” e ruim pra “o que aconteceu com essa request há onze meses?”
Explicação é artefato endereçado. Chaveada pelo request ID. Guardada num data store (ou reproduzível de um snapshot). Boa pra “o que aconteceu com essa request?” e ruim pra “qual a taxa de warning?”
Os dois precisam um do outro. A linha de log carrega o request ID e o snapshot ID; a explicação carrega o contexto completo. O dashboard lê o log e plota a taxa; o investigador lê a explicação e reconstrói o caminho.
Um formato simples:
// Na hora do Execute, emite uma linha de log.
log.Info("rule.engine.execute",
"correlation_id", explanation.CorrelationID,
"request_id", explanation.RequestID,
"snapshot_id", explanation.SnapshotID,
"fired_count", len(explanation.Actions),
"duration_ms", explanation.Duration.Milliseconds(),
"warning_count", len(explanation.Warnings),
)
// Em separado, emite a explicação completa pra explanation store
// (síncrono pra tráfego sampleado, assíncrono pra debug-flagado).
explanationStore.Put(ctx, explanation)
A linha de log é suficiente pra dashboard e alerta. A explicação é suficiente pra investigação. Nenhuma é suficiente sozinha.
O workflow de investigação
A razão pra construir explicabilidade é tornar investigação barata. O workflow de investigação, quando o sistema é bem construído, é:
- Suporte ao cliente sinaliza uma reserva com um correlation ID.
- O engenheiro consulta a explanation store:
explanationStore.Get(correlation_id). - Se a explicação tá na store (sampleada ou debug-flagada), retorna na hora.
- Se não, o serviço de replay é invocado: carrega o snapshot a partir do snapshot ID embutido na linha de log original, carrega os facts do fact log, e roda o engine de novo pra reconstruir a explicação.
- O engenheiro lê a explicação e responde a pergunta.
É isso que transformou a investigação de quatro dias da história de abertura em trinta minutos nos trimestres seguintes. O mesmo workflow, com os mesmos artefatos, atende escalada de engenharia, pergunta de produto, e pedido de auditoria.
O workflow tem um pré-requisito: snapshot tem que ser retido. O snapshot é o que torna o replay determinístico. Sem o snapshot, replay exige reconstruir o rule set do histórico do git, que funciona só se a compilação do rule set pelo engine é em si reproduzível a partir do source. (Em geral é, mas os dias em que você descobre que não é são os dias em que queria ter guardado o snapshot.)
O que a explicação habilita
Três benefícios duradouros que pagam o investimento de engenharia.
Postmortem fica mais rápido. Todo incidente tem um request ID. Todo request ID tem uma explicação. O postmortem começa pela explicação; a análise de root cause é o diff entre o que a explicação mostrou e o que o time esperava.
Suporte ao cliente para de escalar. O agente respondendo “por que fui cobrado disso?” tem a explicação. O dono de produto não precisa ser paginado pra pergunta de rotina. O time de engenharia não precisa ser o primeiro ponto de contato pra curiosidade de pricing.
Compliance vira problema de ferramenta, não de heroísmo. Da próxima vez que o regulador perguntar, a resposta é uma query no banco, não quatro dias-engenheiro. A defensibilidade do sistema é propriedade embutida, não extraída.
O custo — schema, armazenamento, serviço de replay, retenção de snapshot — é real. Também é limitado. A primeira versão são poucas centenas de linhas de código. A versão completa são alguns milhares. O benefício se acumula em todo incidente, toda escalada, toda auditoria, enquanto o engine rodar.
O que a explicação não faz
Duas coisas que pedem pra explicação fazer, e que ela não devia fazer.
Não substitui o arquivo de regra. O arquivo de regra é a intenção que o autor escreveu. A explicação é o resultado avaliado. Confundir os dois é como o time acaba “editando a explicação” pra arrumar um bug, o que não arruma nada e obscurece a próxima investigação.
Não é a trilha de auditoria do engine. A trilha de auditoria é o snapshot + o fact log + o histórico da regra no git. A explicação é a visão através disso, gerada pra uma request. Auditoria em mil requests são mil explicações, geradas contra os mesmos snapshots. A explicação não é o armazenamento; é a projeção.
Essas distinções soam pedantes até você ver um time tentando guardar toda explicação pra sempre como “a trilha de auditoria”. O custo acumula, o schema começa a mudar, e o data store vira o componente mais caro do sistema. O modelo de replay-sob-demanda é o que mantém o custo são.
O que vem a seguir
O próximo post é tráfego sintético — o jeito de dar pro engine inputs que ele ainda não viu, de propósito, pra encontrar os bugs que vai encontrar depois. Tráfego sintético é o que torna shadow mode e simulação por replay significativos nos dois posts seguintes. É também onde o segundo repositório de referência, traffic-gen
, entra na história.
Tráfego sintético e explicação são complementares. Tráfego produz request; o engine produz explicação. Uma sessão de replay contra um rule set candidato gera explicação pra cada request sintética, e a diferença entre a explicação candidata e a atual é a avaliação de impacto da regra candidata. Os próximos três posts são sobre fechar esse loop.
Por enquanto, a lição é o contrato. Um sistema de pricing que não consegue explicar uma decisão não consegue ser operado com segurança. A explicação é a promessa que o sistema faz pro operador, pro auditor, pro dono de produto e pro engenheiro — que o caminho que o engine pegou é recuperável. Essa promessa não é de graça, mas é o seguro mais barato que o engine carrega, e é a peça do sistema em que você vai estar feliz de ter superinvestido na primeira vez que um regulador faz uma pergunta.
O cliente da história de abertura não era, no fim, a pergunta. A categoria de decisão era. O próximo e-mail de regulador chegou sete meses depois. A resposta levou trinta minutos. O sistema, àquela altura, já não tava pedindo pra gente defender ele; tava se defendendo sozinho.