Pular para conteúdo

Cache de revisão: identidade, evidência entre arquivos e comportamento de degradação (AUR-513)

Este documento estende o cache de revisão por arquivo do AUR-441 (cmd/aurumcode/review_cache.go, internal/review/cache). Veja docs/specs/AUR-441.md para o desenho original (uma entrada de cache por arquivo alterado, chaveada pelo conteúdo do diff bruto desse próprio arquivo, mais a identidade do modelo e uma constante de versão do prompt). Aqui está o que o AUR-513 acrescenta: toda outra entrada que pode mudar a resposta do modelo, como um acerto por arquivo preserva a evidência entre arquivos e como um cache quebrado degrada.

O que agora faz parte da chave do cache

reviewContextCacheKey (cmd/aurumcode/review_cache.go) incorpora, além dos três itens originais do AUR-441 (idioma da revisão, pacote de contexto da base de código, notas de memória):

  • O modelo/endpoint que de fato responde, capturado antes de qualquer encapsulamento de contexto (baseModelIdentity): modelCacheKey(provider) chamado sobre provider no instante em que ele é selecionado, ANTES de config.WrapProviderWithWarnings ter qualquer chance de encapsulá-lo. Isto não é redundante com a chamada modelCacheKey(provider) que reviewContextCacheKey ainda faz sobre o provider final (a essa altura, possivelmente encapsulado e possivelmente encapsulado por --limite) — veja "Por que a identidade do modelo é capturada duas vezes" abaixo para o bug que isso fecha.
  • Perfil(is) de revisor selecionado(s) (AUR-502): a concatenação de reviewprofile.Profile.Signature() de cada perfil selecionado, na ordem de seleção. A ordem importa porque reviewprofile.MergeFindings atribui um achado compartilhado ao perfil mais antigo na ordem de declaração; portanto, reordenar o mesmo conjunto de nomes pode mudar a resposta. Antes deste card, partitionByCache era chamada com uma chave calculada antes de runProfilePasses saber quais perfis foram escolhidos, de modo que duas revisões do mesmo diff sob perfis diferentes podiam, por engano, compartilhar uma única entrada de cache.
  • O conteúdo do bloco de contexto montado e REDIGIDO (prompt do repositório
  • skills + docs e, sob uma política central, o da própria política — AUR-518): contextBlockCacheDigest faz o hash do mesmo bloco que config.BuildContextBlockWithWarnings monta para o prompt de saída real. Veja "Por que o digest do contexto faz hash do bloco redigido" abaixo.
  • O catálogo dinâmico de regras/seções de skill (AUR-519): ruleCatalogCacheDigest faz o hash da lista ordenada de IDs do catálogo de regras e do mapa dynamicRules ordenado (o review.Rule completo de cada entrada, não só o ID) — é isso que o modelo aprende e contra o que o gate aceita citações. Veja "ruleCatalogDigest: defesa em profundidade, não exercitado isoladamente de ponta a ponta" abaixo para o estado atual dos testes.

Duas revisões idênticas em todos esses itens podem reutilizar legitimamente uma entrada de cache; uma diferença em qualquer um deles deve produzir uma chave diferente.

Por que a identidade do modelo é capturada duas vezes

config.contextInjectingProvider (internal/config/wrap.go, o tipo que WrapProviderWithWarnings devolve assim que QUALQUER contexto é configurado — um prompt do repositório, uma skill, um doc) embute llm.Provider como um campo de interface. Go só promove métodos declarados no tipo estático do campo embutido; logo, um método que o provider concreto subjacente também implementa, mas que não faz parte da própria interface llm.Provider, não é promovido pelo wrapper. llm.ModelResolver (ResolveModel) é exatamente um método assim: internal/llm/provider/litellm.Provider o implementa (reportando o modelo realmente configurado), mas, uma vez encapsulado, uma asserção de tipo provider.(llm.ModelResolver) sobre o valor encapsulado falha, e modelCacheKey recorre silenciosamente ao literal fixo "litellm" de litellm.Provider.Name() — sem modelo, sem endpoint.

Antes desta correção: com QUALQUER doc/skill/prompt configurado (comum), duas revisões contra o mesmo endpoint com dois valores diferentes de LLM_MODEL colidiam numa só entrada de cache, porque a única coisa que as distinguia (o modelo reportado por ResolveModel) tinha se tornado invisível. O fixedModelProvider de cmd/aurumcode/cost.go documenta esse mesmo risco de perda de promoção para outro wrapper e o contorna implementando ResolveModel diretamente em si mesmo — mas isso só corrige o wrapper próprio do --limite, não o wrapper de contexto que está por baixo, e apenas quando --limite é realmente usado.

A correção: runReview (cmd/aurumcode/main.go) chama modelCacheKey(provider) imediatamente após a seleção do provider e antes de qualquer encapsulamento, captura o resultado como baseModelIdentity e passa essa string a reviewContextCacheKey incondicionalmente. A chamada preexistente modelCacheKey(provider) dentro do próprio reviewContextCacheKey é mantida, inalterada, porque o fixedModelProvider do --limite (encapsulado depois, sobre o wrapper de contexto, e implementando ResolveModel diretamente — sem depender de promoção) ainda precisa desse valor capturado tarde para a contabilidade da chave de custo, exatamente como antes do AUR-513; removê-la regrediria tests/acceptance/AUR-433.sh. Os dois valores são complementares, não alternativas.

modelCacheKey também passa a incorporar LLM_BASE_URL diretamente (dois endpoints que servem um modelo com o mesmo nome não são a mesma entidade respondente). TestAUR513ModelIdentitySurvivesContextWrapping (cmd/aurumcode/aur513_test.go) prova as duas metades: uma verificação unitária de que dois litellm.Provider com modelos E URLs base diferentes, encapsulados pelo mesmo contexto, ainda produzem chaves de cache de revisão diferentes (e uma asserção de sanidade de que o modelCacheKey encapsulado sozinho colide, demonstrando o bug que isso fecha); e uma execução de ponta a ponta contra um httptest.Server real com contador de requisições, em que trocar LLM_MODEL entre duas rodadas contra um cache persistido força uma segunda requisição de verdade.

Por que o digest do contexto faz hash do bloco redigido

contextBlockCacheDigest faz o hash do bloco que config.BuildContextBlockWithWarnings monta — o MESMO texto, já redigido, que a chamada real envia ao modelo — em vez da saída bruta, pré-redação, de cada provider.

Uma revisão anterior desta função fazia hash da saída bruta dos providers justamente para evitar uma colisão: redaction.Filter.Redact substitui todo trecho com formato de segredo pelo mesmo marcador fixo [REDACTED]; assim, dois arquivos que diferem apenas no valor de um segredo podem ser redigidos para um texto idêntico byte a byte, e um teste que mudava apenas um marcador com formato de segredo entre rodadas provou exatamente isso — o digest não se movia, porque o texto sobre o qual era calculado também não se movia, uma vez que a identidade do modelo e a abordagem sobre texto bruto discordavam do que o modelo realmente recebe.

Essa colisão é real, mas fazer hash do texto bruto para driblá-la responde a uma pergunta diferente de "isto deve contar como acerto de cache": o modelo só vê o prompt REDIGIDO. Se duas configurações produzem o bloco redigido idêntico, o modelo responderia identicamente nos dois casos, e reutilizar a resposta em cache está correto — não é uma fraqueza, é apenas aquilo que o próprio modelo já não consegue distinguir. Fazer hash do texto bruto dos providers tornaria o cache MAIS conservador que o modelo para o qual ele guarda respostas, forçando re-revisões desnecessárias por uma diferença que o modelo nunca vê, sem mudar em nada a história de exposição de segredos (o digest, bruto ou redigido, é de qualquer forma uma string hex sha256 de mão única — o padrão já estabelecido de cache.Key para o conteúdo do diff de um arquivo —, então "nenhum segredo em forma legível" vale nas duas versões).

TestAUR513AC002DocContentChangeForcesFreshReview portanto muda uma linha claramente visível (prosa comum, sem formato de segredo) entre as rodadas, de modo que o teste realmente exercite "o texto configurado chega ao digest" e não uma colisão de redação sob a qual ambas as versões se comportariam de forma idêntica.

Isso ainda chama config.BuildContextBlockWithWarnings uma segunda vez em vez de ler as entranhas do provider já encapsulado, porque contextInjectingProvider não é exportado e não expõe nenhum ponto de costura para um chamador fora de internal/config recuperar o texto do seu bloco, e os paths deste card não incluem internal/config. Todo provider que ConfiguredProviders devolve (RepoPromptProvider, FileContextProvider, TextContextProvider, PathInstructionsProvider) lê um arquivo local e não faz mais nada; para eles, uma segunda chamada é determinística e livre de efeitos colaterais. As fontes MCP de review.context.mcp também entram na lista de providers passada ao digest (review_base_analysis.go e review_pr_model.go): montar o bloco só para o digest consulta cada servidor MCP de novo, e uma resposta diferente do servidor muda a chave de cache. Evitar essa segunda consulta exigiria memoizar o bloco de uma mesma revisão para o digest e para o prompt.

ruleCatalogDigest: defesa em profundidade, não exercitado isoladamente de ponta a ponta

Hoje, todo arquivo de skill que dynamicRulesFromLocalSkills lê (review.ParseSkillSections) vem exatamente dos mesmos arquivos que o bloco já hasheado por contextBlockCacheDigest contém; assim, na prática, uma mudança de conteúdo de skill que altera o catálogo de regras derivado também altera o digest do bloco de contexto, e os testes deste card invalidam o cache por esse caminho compartilhado, em vez de isolar este diretamente. ruleCatalogDigest é mantido mesmo assim, incorporado de forma independente à chave, porque os dois podem divergir sem aviso no futuro: uma entrada de catálogo derivada com uma normalização que os bytes brutos do bloco não refletem, uma fonte de regras exclusiva da política central, ou qualquer contribuição de regra que não seja simplesmente "os bytes de um arquivo de contexto" mudaria o que o modelo aprende e o que o gate aceita sem necessariamente mudar o próprio bloco de contexto. Esta é uma lacuna documentada, não uma alegação de prova de ponta a ponta para este componente isoladamente.

AC-003: um acerto por arquivo não pode descartar evidência entre arquivos

Abordagem escolhida: apoiar-se no pacote de contexto da base de código já ser calculado a partir do diff completo, sem partição, antes de qualquer consulta de cache por arquivo, e já fazer parte da chave do cache.

Em runReview (cmd/aurumcode/main.go), resolveCodebaseContext(diff) roda sobre o diff completo revisado — todo arquivo alterado, acerto ou falha — e só depois partitionByCache reduz o diff enviado ao modelo apenas às falhas (toSend). codebasectx.Resolver.Resolve (internal/context/resolver.go) lê o conteúdo de cada caminho alterado a partir do checkout e extrai seus símbolos definidos e as arestas de referência entre arquivos para Pack.Symbols/Pack.References/Pack.Dependents, independentemente de quais arquivos acabarão sendo acertos de cache. Esse pacote é serializado em codebaseContextText, que é (a) embutido literalmente no prompt de saída (a seção "## Codebase context" de internal/prompt/builder.go, via review.ReviewContext.CodebaseContext) e (b) uma das entradas de reviewContextCacheKey. Assim, os símbolos de um arquivo com acerto de cache ainda chegam ao modelo que revisa um arquivo irmão alterado, e uma mudança nesse quadro entre arquivos ainda invalida as entradas certas.

Concretamente: se lib.go define HelperZZZ e é revisado uma vez junto com app.go (que o chama), então, numa rodada posterior em que só app.go muda de novo e o diff de lib.go contra a base fixa é idêntico byte a byte ao da rodada em cache (um acerto de cache genuíno, nunca reenviado), HelperZZZ ainda aparece no pacote de contexto da base de código dessa rodada, porque o pacote foi construído a partir dos caminhos dos dois arquivos antes de a partição rodar. TestAUR513AC003CrossFileEvidenceSurvivesPartialHit (cmd/aurumcode/aur513_test.go) prova isso de ponta a ponta, via AURUMCODE_PROMPT_CAPTURE: ele analisa especificamente a seção JSON ## Codebase context do prompt capturado e afirma que HelperZZZ está no seu campo Symbols — NÃO apenas uma checagem de substring contra o prompt inteiro, porque o diff do próprio app.go contém literalmente o texto HelperZZZ() no ponto de chamada, o que faria uma checagem de substring pura passar mesmo com um pacote de contexto vazio. O seletor AC-003-MUT-001 de tests/acceptance/AUR-513.sh muta a construção de reviewCtx para resolver o contexto da base de código a partir de toSend (o diff já particionado, só com falhas) em vez do diff completo, reproduzindo a regressão que este desenho existe para recusar, e confirma que o teste fica VERMELHO.

Alternativa considerada e rejeitada: recusar servir um acerto de cache para qualquer arquivo do qual o conjunto de arquivos recém-alterado possa depender. Isso exige um grafo de dependências que o pacote de cache não tem, e construir e manter um confiável (quais arquivos dependem estaticamente de quais, transitivamente, entre linguagens) é um empreendimento muito maior do que o Outcome deste card pede, e arrisca a falha oposta — tratar um arquivo não relacionado como "dependido" e forçá-lo a ser reenviado para sempre, frustrando silenciosamente todo o propósito do cache. O pacote de contexto da base de código já dá ao modelo o quadro entre arquivos de que ele precisa sem esse grafo, e já estava ligado antes deste card exatamente por essa razão (AUR-515/AUR-536); o trabalho deste card foi confirmar, por teste, que nada na partição do cache estreita o que esse pacote enxerga, e incorporá-lo (junto com as demais novas entradas de identidade da revisão) corretamente à chave.

AC-004: um cache quebrado degrada para uma revisão nova, nunca para uma aprovação

Dois pontos de falha independentes, ambos preexistentes no desenho do AUR-441 e inalterados por este card, exceto por serem aqui provados por teste:

  • Uma entrada corrompida (cache.Cache.Get, internal/review/cache/cache.go): um erro de leitura ou de parse de JSON devolve (nil, false, err). partitionByCache (cmd/aurumcode/review_cache.go) só trata uma consulta como acerto quando getErr == nil && ok; qualquer outro resultado — inclusive um erro de parse numa entrada truncada/corrompida em disco — devolve esse arquivo à lista de falhas, de modo que ele é revisado do zero e seus achados reais aparecem normalmente. TestAUR513AC004CorruptCacheEntryDegradesToFreshReview escreve uma entrada corrompida por cima de uma entrada real em cache e prova que a segunda rodada, sob o mesmo AURUMCODE_LLM_FIXTURE da rodada 1 (deliberadamente: mudar o fixture entre as rodadas também mudaria modelCacheKey e forçaria uma chave nova por si só, sem provar nada sobre o caminho da entrada corrompida em especial), nunca imprime uma nota de reutilização e de fato (re)escreve um arquivo AURUMCODE_PROMPT_CAPTURE nomeando app.go. AC-004-MUT-001 (tests/acceptance/AUR-513.sh) muta partitionByCache para tratar qualquer erro de Get como um acerto com zero issues e confirma que este teste fica VERMELHO.
  • Um diretório de cache inutilizável (cache.Open, internal/review/cache/cache.go): um diretório que não pode ser criado ou usado devolve um erro. runReview trata isso exatamente como "nenhum cache configurado para esta execução" — toSend continua sendo o diff completo, sem partição, e persistFreshResults/mergeCacheHits são ignorados por completo (condicionados a cacheErr == nil). O cache é uma otimização de melhor esforço, nunca um portão de corretude. TestAUR513AC004UnreadableCacheDirDegradesToFreshReview aponta AURUMCODE_CACHE_DIR para um caminho que já existe como arquivo comum (de modo que os.MkdirAll falha) e prova que a revisão ainda roda por inteiro e ainda reporta o achado real.

Nos dois casos, vale a garantia relevante para o gate já estabelecida para AUR-441/AUR-519: uma falha de cache só pode custar uma chamada repetida ao modelo, nunca transformar silenciosamente um resultado inconclusivo/bloqueado em aprovação, e nunca omitir um arquivo da cobertura.

Lacuna fechada: o componente de versão do prompt na chave agora é um digest em tempo de execução (AUR-543)

Antes deste card, internal/review/cache.PromptVersion ("v1") era uma alavanca incrementada à mão: no dia em que o prompt embutido ou o catálogo de regras embutido mudasse de um jeito que pudesse alterar os achados de um arquivo, um humano tinha de lembrar de incrementar esse literal manualmente, ou toda entrada de cache construída sob o prompt antigo continuaria sendo servida sob o novo.

internal/prompt.PromptBuilder.FixedContentDigest() fecha essa lacuna. Ele chama o próprio BuildPrompt — o ponto de entrada público exato que as chamadas de revisão em produção usam — DUAS vezes, uma para cada par fixo sentinela de diff/opções (fixedContentSentinelPairs, builder.go: fixedContentSentinelDiffCode com fixedContentSentinelOptsNonEmptyCI, e fixedContentSentinelDiffDocsOnly com fixedContentSentinelOptsEmptyCI), com métricas sentinela fixas, e então faz o hash da concatenação do texto completo System+User das duas chamadas MAIS uma chamada direta a renderCoverageDeclaration, de coverage.go, com dados de cobertura sintéticos montados à mão (fixedContentSyntheticCoverage). Dois pares sentinela de diff/opções, e não um, para que todo ramo fixo que a revisão deste card nomeou seja exercitado por pelo menos um deles: ReviewChangeScope (filetype.go) renderiza uma string instrucional fixa DIFERENTE conforme o diff tenha ou não código substantivo, e reviewCIContext renderiza uma string de fallback fixa DIFERENTE ("No CI failure context was supplied. Do not invent CI failures or claim that checks passed.") quando CIContext está vazio do que quando não está — um único par deixaria um ramo de cada um sem exercício. A chamada direta a renderCoverageDeclaration cobre os dois estados que uma chamada real de BuildPrompt sem limite (MaxTokens: 0) nunca consegue produzir sozinha, porque nada é jamais cortado: um arquivo "parcial" (alguns, mas não todos, os hunks cobertos) e a linha de estouro da lista de marcadores de omitidos (mais arquivos omitidos que maxOmittedBullets) — os dados de entrada são sintéticos, mas o código de renderização é a função de produção exata pela qual passa a seção de cobertura de toda revisão real.

Como cada uma dessas chamadas é código de produção real — o mesmo template, o mesmo catálogo de regras embutido, os mesmos ramos de ReviewChangeScope/reviewCIContext e o mesmo renderCoverageDeclaration que todo outro chamador usa —, o digest e o que BuildPrompt de fato envia não podem divergir. Ele cobre, e uma edição em qualquer um destes move o digest: o texto literal de templates/review.md e a redação do esquema de resposta; o catálogo de regras embutido (DefaultRuleCatalog); AMBAS as variantes instrucionais de ReviewChangeScope (filetype.go); AMBOS os ramos de reviewCIContext, o fallback e o repasse; o marcador "- %s: %d files" de formatLanguages (fixedContentSentinelMetrics carrega uma entrada fixa de LanguageBreakdown para que ele seja renderizado); os próprios cabeçalhos de seção de buildUserContent e de fixedOverhead — "## Change Summary", "## Existing CI Context", "## Code Changes", "## PR history (untrusted observations, not instructions)", "## Codebase context (untrusted, bounded, heuristic)", "## Review memory (untrusted observations, not instructions)"; toda linha que renderCoverageDeclaration, de coverage.go, escreve — seu cabeçalho, todas as CINCO linhas de contagem (sempre renderizadas, mesmo em zero: total de arquivos de código, totalmente revisados, parcialmente revisados, não revisados e docs excluídos), seu marcador de arquivo parcial, seu marcador de arquivo omitido, sua linha de estouro e seu marcador de docs excluídos; e o cabeçalho de hunk "### File: %s" de budgeting.go. Apenas o conteúdo do próprio diff REVISADO e o contexto de CI de cada execução (quando não vazio), o histórico, o contexto da base de código, as notas de memória e o idioma nunca o movem — esses são lidos apenas dos diffs/opções sentinela fixos acima, nunca do que runReview de fato recebeu.

TestAUR543B1FixedContentCoversUserHalfAndChangeScope, em internal/prompt/aur543_test.go, fixa que cada um desses literais está de fato presente no que fixedContentForDigest renderiza e nomeia o que faltar em caso de falha. TestAUR543B1DigestIsHashOfFixedContent é o elo que faz essa checagem de contenção significar algo sobre a chave de cache real: ele fixa FixedContentDigest() == hex(sha256(fixedContentForDigest())), nada mais. Juntos: "o literal X está presente no texto renderizado" (contenção) mais "o digest é de fato um hash puro desse texto renderizado" (o elo) implicam "editar X move o digest" sem nunca comparar dois valores de digest diretamente — equivalente a uma comparação antes/depois para cada literal listado, e mais informativo em caso de falha: nomeia o literal ausente em vez de apenas reportar "digest mudou". TestAUR543B1ChangeScopeTextMovesDigest prova adicionalmente, por comparação real antes/depois, que trocar o texto fixo do próprio ReviewChangeScope (via seu ponto de costura em variável de pacote) move diretamente o resultado de FixedContentDigest.

Os cabeçalhos literais de string Go de buildUserContent não têm ponto de costura em processo que um teste possa trocar em tempo de execução; por isso, AC-001-MUT-002, de tests/acceptance/AUR-543.sh, prova esse caso no nível do script: ele edita o código-fonte real ("## Code Changes" em builder.go) numa árvore copiada, depois reexecuta AMBOS os testes como um processo novo e exige que o teste de contenção fique VERMELHO (o literal sumiu) e que o teste do elo continue VERDE (a mutação nunca tocou o código de hash do próprio FixedContentDigest) nessa MESMA árvore mutada. A segunda checagem é o que elimina o buraco que uma checagem só com contenção VERMELHA deixaria passar: um FixedContentDigest fixado no valor hex de hoje também faria o teste de contenção "sobreviver" por coincidência (conteúdo mudou, hash não) sem que a checagem do elo o pegasse.

O que este digest NÃO cobre, e não pode cobrir: literais que internal/prompt não possui. Dois pontos de chamada, ambos fora dos paths deste card (apenas read_paths), acrescentam seu próprio texto fixo pequeno DEPOIS que PromptBuilder.BuildPrompt retorna, e uma edição em qualquer uma das duas junções não moveria este digest:

  • internal/review/reviewer.go (Reviewer.GenerateReview): fullPrompt := promptParts.System + "\n\n" + promptParts.User. A junção de duas quebras de linha é um literal fixo que este digest não lê — ela é reproduzida de forma independente dentro de fixedContentForDigest (também "\n\n"), então hoje os dois por acaso concordam, mas nada impõe isso; uma edição na junção do próprio reviewer.go não mudaria a chave do cache.
  • contextInjectingProvider.Complete, de internal/config/wrap.go: prompt + "\n\n" + p.block. O CONTEÚDO de p.block anexado já é coberto por contextBlockCacheDigest (o parâmetro contextBlockDigest do próprio reviewContextCacheKey, cmd/aurumcode/review_cache.go), então uma edição em um prompt de repositório/política configurado ou em um arquivo de skill de fato invalida o cache; a junção fixa "\n\n" em si, não.

Os dois resíduos são literais de dois caracteres, não texto instrucional ao qual um modelo reagiria de forma diferente, e um futuro card com escopo em qualquer um dos arquivos poderia incorporar um digest equivalente a partir dali se isso mudar.

runReview (main.go), de cmd/aurumcode, calcula esse digest chamando newCacheDigestBuilder() (review_cache.go) — um ponto de costura em nível de pacote que por padrão é prompt.NewPromptBuilder, e não o builder do próprio revisor, cujo ruleCatalog pode carregar o catálogo dinâmico, expandido por skills, desta execução. O ponto de costura existe para que TestAUR543AC001PromptEditForcesFreshReview possa substituir um builder com conteúdo fixo diferente e provar o AC-001 pela chamada real a runReview — as entradas de cache de fato invalidando de ponta a ponta —, e não só pelo resultado unitário do próprio FixedContentDigest; o código de produção nunca o reatribui. runReview passa o resultado a partitionByCache como o argumento promptVersion de cache.Key, no lugar da antiga constante cache.PromptVersion. Usar um builder novo é deliberado, não um descuido: o catálogo de regras dinâmico/expandido por skills que uma execução ensina ao modelo já tem seu próprio digest, incorporado separadamente (ruleCatalogDigest, incorporado ao argumento model de reviewContextCacheKey); fazer o digest dele uma segunda vez aqui o contaria em dobro em vez de proteger algo novo. O próprio cache.PromptVersion continua definido, sem uso pelo código de produção, apenas porque o parâmetro promptVersion de cache.Key é genérico e tests/unit/AUR-441.go (fora dos paths deste card) ainda referencia o literal.

Se o cálculo do digest em tempo de execução alguma vez falhar — o template de produção é compilado via go:embed e sempre faz parse, mas um builder cujo conjunto de templates está vazio (prompt.NewPromptBuilderWithoutTemplates(), usado apenas por TestAUR543N1DigestErrorDegradesToNoCache) esbarra no mesmo erro já publicado, "the review prompt template is unavailable", que buildBasePrompt devolve a qualquer outro chamador —, runReview incorpora esse erro em cacheErr exatamente como uma falha de cache.Open: TANTO partitionByCache (o diff é enviado por inteiro) QUANTO persistFreshResults (condicionado a cacheErr == nil) são ignorados, de modo que todo o cache degrada para "sem cache nesta execução" e, criticamente, não escreve NADA sob uma chave sem sentido — nunca uma falha, nunca uma entrada obsoleta ou errada.

A mutação AC-001-MUT-001 de tests/acceptance/AUR-543.sh faz FixedContentDigest fazer hash de um literal de string fixo em vez do conteúdo renderizado real — reproduzindo exatamente o defeito que este card fecha, um digest que nunca se move por mais que o texto fixo do prompt mude — e confirma que todo teste TestAUR543AC001* fica VERMELHO, tanto no nível unitário (internal/prompt) quanto pela fiação real de produção (TestAUR543AC001PromptEditForcesFreshReview, cmd/aurumcode). Uma segunda mutação (N1-MUT-001) remove a incorporação cacheErr = promptDigestErr em main.go, reproduzindo uma falha de digest que silenciosamente NÃO desativa o cache, e confirma que TestAUR543N1DigestErrorDegradesToNoCache fica VERMELHO.