Pular para conteúdo

AUR-364 — Fixar ferramentas de documentação

Documento histórico do board. Pode descrever componentes removidos; para uso atual, consulte o guia do produto.

Este documento descreve o esquema, os limites, os códigos de erro, o exemplo sintético, o diagnóstico e o comando OCI offline do card AUR-364.

Artefatos do card:

Caminho Papel
.board/bootstrap/locks/docs.yml o lock derivado (schema: bootstrap-lock-v5)
tests/acceptance/AUR-364.sh o oráculo e o gerador (--generate, --attest)
tests/bootstrap/locks/AUR-364/resolved.lock o artefato de resolução (versão exata + digest por par)
tests/bootstrap/locks/AUR-364/read-paths.attested o selo dos read_paths do card
tests/bootstrap/locks/AUR-364/cases.tsv a tabela de casos adversariais
tests/bootstrap/locks/AUR-364/mutations.sh as mutações, uma por caso
tests/bootstrap/locks/AUR-364/verify-fixtures.sh o executor das fixtures e os seis oráculos
docs/specs/AUR-364.md este documento

read_paths do card, na ordem do card:

  1. Gemfile
  2. .aurumcode/Gemfile
  3. .docker/docs.Dockerfile
  4. .github/workflows/documentation.yml

1. Por que o desenho anterior foi vetado (rodadas 1 a 3)

As rodadas se sucederam fechando instâncias enquanto a classe sobrevivia. A classe tem nome: o aceite classifica por ramo — um ramo por tipo de entrada, uma heurística por sintaxe — e sempre existe um ramo em que a checagem simplesmente não acontece.

  • r2 verificava completude por cardinalidade mecânica, com uma função count_real_citations() que era ela mesma uma lista de verbos de instalação. RUN pipx install ... e curl ... | bash não moviam nenhum dos dois lados da igualdade e AC-001 retornava pass.
  • r3 apagou a lista de verbos (e ela continua apagada), mas trocou o problema de lugar. As duas revisões cegas derrubaram r3 com quatro achados que são a mesma classe em dois disfarces:
# Achado Consequência reproduzida com oci-run real
B1 a heurística de pin era “a constraint contém um dígito” gem "x", "0" e gem "x", ">= 0" — que casam com qualquer versão publicada — saíam pinned: true / pinned-lockable
B2 o pin era calculado por nome, sobre a concatenação das constraints de todos os manifestos um manifesto que pina lavava a declaração genuinamente sem versão do outro (já visível nos dados comitados: jekyll-seo-tag, jekyll-sitemap, webrick)
B3 declaração sem versão recebia rótulo unpinned-must-not-install e passava, porque o oráculo só conferia que seus próprios contadores concordavam entre si consistência interna não é propriedade de segurança
B4 a atestação comparava o número de linhas com o número de read_paths e não marcava caminho já visto duplicar uma linha no lugar de outra mantinha a contagem e tirava um arquivo inteiro do selo, com curl | sh vivo no Dockerfile

Os quatro passavam pelo fluxo honesto (--generate + --attest reais), com exit_code: 0 e result: pass saindo do container selado.

2. O reprojeto da rodada 4: parar de julgar constraint, ler a resolução

A análise semântica de linguagem de constraint foi apagada, não estendida. Não existe mais campo pinned, nem parsing de operador, nem teste de dígito, nem qualquer noção de “isto é um pin de verdade?” em lugar nenhum do arquivo. Aquela superfície era ilimitada (quatro ecossistemas, todo operador, todo limite degenerado) e era a fonte direta de B1, B2 e B3.

No lugar dela entra a propriedade que um lockfile resolvido já tem por construção — exatamente uma versão resolvida por ferramenta:

PROPRIEDADE R. Para todo manifesto M e todo nome de dependência N que a gramática de M produz, o artefato de resolução do card contém exatamente um registro (M, N) com uma versão exata de release e um artifact digest. E para todo registro (M, N) na resolução, a gramática de M produz N. Os dois lados são comparados como CONJUNTOS, chaveados no PAR (manifesto, nome) — nunca só no nome (B2), nunca por cardinalidade (B4).

Consequências, enunciadas como totais e não como lista de casos cobertos:

  • gem "x", gem "x", "0", gem "x", ">= 0", gem "x", "~> 0", gem "x", "!= 9.9.9" e toda forma de constraint que um Bundler futuro venha a inventar são tratadas de forma idêntica: a constraint é registrada literalmente no lock (tool[N].version_constraint), marcada constraint_interpreted: false, e nunca é lida por nenhuma decisão. O que vincula é a versão exata da resolução.
  • Declaração sem registro de resolução é falha tipada (docs_tool_lock_incomplete), nunca omissão.
  • Registro de resolução sem declaração é falha tipada (invalid_lock_or_manifest): a resolução não carrega peso morto que nada no repositório pediu.
  • Se a fonte de resolução exigida por um formato de manifesto não existir, isso é falha tipada (docs_tool_lock_incomplete), nunca um pulo silencioso.

RE_EXACT_VERSION (^[0-9]+\.[0-9]+\.[0-9]+$) é a definição inteira de “resolvido” neste card. Três componentes numéricos, sem operador, sem faixa, sem curinga.

2.1 Onde está o lockfile resolvido, e por que não é o Gemfile.lock

O lockfile nativo do ecossistema Bundler é o Gemfile.lock. Este repositório não versiona nenhum: não existem Gemfile.lock nem .aurumcode/Gemfile.lock, e nenhum dos dois é read_path deste card, logo nenhum dos dois é materializado dentro do container do aceite.

Esse fato é divulgado em limits[3] do lock gerado e aqui — não é contornado pulando a checagem. O card carrega a sua própria fonte de resolução, tests/bootstrap/locks/AUR-364/resolved.lock, dentro de paths: como o Public contract exige, e a ausência dela é falha tipada. Quando um Gemfile.lock real for versionado, ele precisa ser acrescentado aos read_paths do card antes de poder ser honrado; até lá, required_resolution_of_format aponta para o artefato card-scoped, e isso está declarado em nonderivable[5].

resolved.lock é mantido à mão e nunca é escrito por --generate ou --attest. Isso é deliberado: é a verdade de referência contra a qual a Propriedade R é comparada, e uma verdade de referência gerada a partir da coisa que ela verifica não provaria nada (Regra dos Seis, item 6).

2.2 Derivação 2 (inalterada, e não quebrada por nenhuma das duas revisões)

Todo read_path que não é manifesto legível por máquina não é interpretado: entra no lock como o sha256 do arquivo inteiro, selado uma segunda vez em read-paths.attested, que --generate nunca escreve. brew install, curl ... | sh, um blob base64 ou uma sintaxe que ninguém inventou ainda mudam os bytes do arquivo e todos reprovam — não porque este card os reconheça, mas porque nunca precisou reconhecer.

3. Conjuntos, nunca contagens

Toda checagem de cobertura marca cada item canônico como visto num array associativo, rejeita duplicata, e depois afirma que cada item foi visto pelo nome. As únicas comparações aritméticas que sobraram no aceite carregam uma das três etiquetas:

Etiqueta Significado
# NONEMPTY Lei 12 item 4: conjunto derivado de tamanho zero é FALHA
# BOUND teste de tamanho ou limite contra uma constante declarada
# ACCOUNTING Regra dos Seis item 3: a soma de classificação por linha dentro dos parsers, que prova que nenhum continue largou uma linha física

O oráculo set-not-count-audit de verify-fixtures.sh re-grepa o aceite inteiro e reprova se qualquer comparação aritmética estiver sem etiqueta, ou se qualquer comparação tiver uma cardinalidade de conjunto dos dois lados — exatamente a forma que deixou B4 passar. O detector é validado contra um controle positivo sintético (uma linha sem etiqueta, uma com etiqueta e uma com duas cardinalidades): se o detector não enxergar as três, o silêncio dele sobre o arquivo real não vale nada (Lei 12).

3.1 A checagem que não acontecia por SIGPIPE

Enquanto esta rodada era construída, um stress de 200 execuções do caso nominal, com bytes idênticos, reprovou 7 vezes com invalid_lock_or_manifest. A causa é a mesma classe sob julgamento, num disfarce novo: set -o pipefail combinado com um consumidor que sai assim que tem resposta (grep -q, head).

# ANTES — o status do pipeline depende de uma CORRIDA
if grep -vE '<forma de chave>' "$lock_file" | grep -q . ; then fail ; fi

Se o grep -q da direita sai antes de drenar, o grep -v da esquerda morre com SIGPIPE, o pipeline reporta 141, e o if lê isso como “não achou nada”: a checagem simplesmente não acontece. No sentido oposto — nas checagens de terminador — a mesma corrida reprovava um nominal legítimo ao acaso.

Toda decisão do aceite passa a ser tomada a partir do status de um único comando ou de uma here-string (grep -Fq ... <<< "$texto"), nunca do status de um pipeline cuja cabeça pode ser morta. O oráculo no-pipefail-race proíbe a forma mecanicamente e é validado por controle positivo. Depois da correção, 200/200 execuções do nominal saem exit 0.

4. Esquema do lock (bootstrap-lock-v5)

Cabeçalho: schema, card, generated_without_network, três derivation[], trusted_registry, o bloco resolution.{path,present,sha256,schema,digest_domain} e o bloco spec.{path,sha256}, que sela este documento: docs/specs/AUR-364.md é path declarado do card, portanto é path lido (Lei 20), e um byte alterado aqui reprova o aceite até que --generate seja rodado de propósito.

Bloco Campos
read_path[N] path, role (manifest|document), format, bytes, lines, sha256
manifest[N] path, format, runtime, source, sha256, required_resolution, lines_total, lines_accounted, declarations
document[N] path, sha256, bytes, lines, interpreted: false, install_instructions: uninterpreted-sealed-by-whole-file-digest
runtime[N] family, resolved_version, artifact_digest, manifests
tool[N] name, declared_in, runtime, manifest_format, version_constraint, constraint_interpreted: false, resolved_version, artifact_digest, runtime_version, declaration_sha256, manifest_sha256, allowlisted_command, output_schema
count.* apenas dados. Nenhuma decisão do oráculo compara dois desses números
violation[N] path, line, kind — o número da linha e a classe, nunca o texto ofensor
limits[N], nonderivable[N] limites divulgados e constantes de engenharia não deriváveis

tool[] tem uma entrada por par (manifesto, nome), ordenada canonicamente por nome e depois por manifesto em LC_ALL=C. É por isso que jekyll aparece duas vezes: .aurumcode/Gemfile e Gemfile são duas declarações distintas, e tratá-las como uma só foi o blocker B2.

4a. O diretório de fixtures declarado, selado arquivo a arquivo (Lei 20)

O quarto path declarado do card é um diretório: tests/bootstrap/locks/AUR-364. Até a rodada 4 apenas dois dos arquivos dentro dele eram abertos pelo aceite (resolved.lock e read-paths.attested); cases.tsv, mutations.sh e verify-fixtures.sh — o aparato de TDD inteiro deste card — podiam ser apagados ou reescritos com o aceite continuando verde. Isso é exatamente a Lei 20 na granularidade de arquivo, e a Lei 12 na forma mais fácil de não ver: o portão estava verde porque a checagem nunca acontecia.

O aceite agora lista o diretório declarado e sela cada arquivo:

Campo Conteúdo
card_artifact.dir o diretório declarado
card_artifact.count quantos arquivos a listagem encontrou
card_artifact[N].{path,bytes,sha256} um registro por arquivo, em ordem de byte (LC_ALL=C)

A lista não é digitada: ela é a listagem do diretório. Um arquivo acrescentado amanhã é selado sem que ninguém edite nada; um arquivo removido some da derivação e sobra no lock comitado. A seção 7k do aceite compara os dois lados como conjunto, nas duas inclusões, marcando cada path por nome:

  • em disco → no lock, com o digest dos bytes de agora: adulteração reprova;
  • no lock → em disco: remoção reprova. Este é literalmente o teste que a Lei 20 prescreve.

Symlink, byte NUL, arquivo vazio e tamanho acima do limite são falhas tipadas antes de qualquer digest. Oito casos de cases.tsv, classe fixture-read-path, executam remoção de cada um dos três arquivos antes invisíveis, adulteração, symlink, arquivo vazio e a direção que ninguém testa: um arquivo acrescentado ao diretório sem reselo.

Consequência operacional: como read-paths.attested está dentro do diretório selado, --attest tem de rodar antes de --generate. Ver §7.

4.1 Esquema da resolução (bootstrap-resolution-v1)

resolution-schema: bootstrap-resolution-v1
digest-domain: synthetic-offline-identifier-v1
runtime<TAB>ruby<TAB>3.2.2<TAB>sha256:<64 hex>
gem<TAB><manifesto><TAB><nome><TAB><versão exata><TAB>sha256:<64 hex>
...
end: bootstrap-resolution-v1

Gramática allowlist, com separação por TAB exata: um campo vazio ou um campo a mais é violação, nunca um valor que o shell dobrou. IFS=$'\t' read não é usado para entrada hostil, porque TAB é IFS whitespace e o bash colapsa sequências dele — a<TAB><TAB>b viraria dois campos em vez de três. O aceite usa split_tabs, que preserva campo vazio e não apara nada. Pelo mesmo motivo, os valores extraídos dos manifestos passam por um tab guard: gem 'webrick<TAB>evil' seria emitido como um registro de webrick — que é resolvido — lavando uma dependência de outro nome para dentro de um registro existente.

Regras adicionais da resolução, todas como propriedades de conjunto:

  • uma identidade de artefato por (nome, versão) (artifact-digest-inconsistent);
  • nenhum digest compartilhado por dois artefatos diferentes (artifact-digest-collision);
  • digest degenerado (0…0 / f…f) é sentinela declarada (artifact-digest-null);
  • uma versão resolvida por nome em todo o card (resolved-version-conflict);
  • toda família de runtime usada por um manifesto tem runtime resolvido, e todo runtime resolvido é usado por algum manifesto (as duas inclusões).

5. Códigos de erro

Esta tabela não é documentação: é um dos dois lados de um portão. A seção 3b do aceite deriva, dos próprios bytes de tests/acceptance/AUR-364.sh, o conjunto de códigos que o script consegue emitir, deriva desta tabela o conjunto documentado, e exige igualdade de conjunto nas duas inclusões. Renomear um código no código e esquecer a tabela reprova; documentar um código que nenhum ramo alcança reprova; conjunto vazio de qualquer lado reprova (Lei 12). Nenhum dos dois lados é digitado à mão em lugar nenhum.

Código Quando
docs_tool_lock_incomplete lock/atestação/resolução/spec ausente ou vazia; declaração sem registro de resolução; manifesto que não declara nada; conjunto derivado vazio
source_integrity_error symlink, byte NUL, read_path ausente/não regular
invalid_lock_or_manifest envelope inválido; linha fora da gramática (manifesto ou resolução); linha/entrada duplicada; registro de resolução sem declaração; papel do lock divergente da derivação; atestação fora da gramática
input_limit_exceeded qualquer bound declarado excedido
docs-tool-unlocked bytes de read_path ou do spec divergentes do selo ou do lock; lock diferente da derivação; tabela de códigos do spec divergente do conjunto emitido
docs-tool-mutable versão resolvida não exata, digest malformado/nulo/colidente; fato resolvido no lock divergente da resolução; campo obrigatório de tool[] ausente
runtime-resolution-inconsistent runtime não resolvido ou com versão não exata; conflito de versão ou de família por nome entre manifestos
docs-tool-source-untrusted manifesto sem source aceito ou com origem fora do allowlist de um
docs-tool-identity-mismatch entrada de tool[] que não corresponde a uma declaração real, duplicada, ou declaração ausente do lock
unknown-selector seletor fora dos quatro aceitos (AC-001, ContractAUR364, IntegrationAUR364, E2EAUR364); sai 64, não 1

5.1 O código que mudou de nome na rodada 5, e por quê

Até a rodada 4 o código acima se chamava runtime-incompatible, e o Outcome do card prometia “runtime compatível”. Uma revisão cega mediu que nenhum ramo deste card compara a versão de uma ferramenta com a versão do runtime: não há comparação semver, não há sort -V, não há tabela de mínimos. Os três ramos que emitiam aquele código testam consistência interna da resolução — runtime não resolvido, versão de runtime não exata, e duas resoluções divergentes para o mesmo nome entre manifestos.

O rótulo prometia uma capacidade que o código nunca teve, e o teste que deveria prová-la (mut002_jekyll_runtime_incompatible) exercitava o ramo do conflito cruzado, não o par ferramenta/runtime que o nome descrevia. O nome do código, o nome do caso, a MUT-002 do card e esta tabela foram todos reescritos para dizer exatamente o que acontece. A lacuna está declarada no limite 6 da seção 6, e o caso disclosed_runtime_not_compatibility_checked a executa: ele resolve o runtime ruby para 2.0.0 mantendo jekyll 4.3.3 e passa, de propósito, com o resultado registrado na fixture.

6. Limites declarados

  1. read_paths documentais são selados por digest de arquivo inteiro e nunca interpretados: o card prova que nenhum byte mudou sem um reselo deliberado; não classifica o que um comando de documentação faz.
  2. Identidade de ferramenta só é derivada de read_path cujo basename é formato registrado de manifesto. Formato conhecido sem parser registrado para o aceite para o aceite, em vez de ser chutado ou rebaixado a documento opaco.
  3. O repositório não versiona lockfile nativo do ecossistema; a fonte de resolução exigida é o artefato card-scoped, e sua ausência é falha tipada.
  4. Os digests vêm da resolução sob o digest-domain declarado e não são verificados contra rubygems.org, porque o aceite não tem rede. O card prova que cada ferramenta tem exatamente um digest bem formado, que uma identidade de artefato mapeia para um digest, que dois artefatos não compartilham digest e que o valor é estável byte a byte entre execuções; não prova que o digest é igual ao checksum do .gem upstream.
  5. Constraints são registradas literalmente e nunca interpretadas: uma constraint que admitiria muitas versões não é nem abençoada nem rejeitada pela sintaxe. O que vincula é a versão exata resolvida.
  6. A versão do runtime é verificada por consistência de resolução, nunca por compatibilidade. O card prova que toda família de runtime usada por um manifesto tem exatamente uma versão exata resolvida, que todo runtime resolvido é usado por algum manifesto, e que toda entrada tool[] cita aquela mesma família e aquela mesma versão. Ele não prova que a versão resolvida do runtime satisfaz o required_ruby_version de nenhuma ferramenta — e, offline, não poderia: esse metadado vive no gemspec upstream e não aparece em read_path nenhum deste card. Concretamente: resolver ruby para 2.0.0 mantendo jekyll 4.3.3 passa, e o caso disclosed_runtime_not_compatibility_checked executa exatamente isso.

Delegar ao resolvedor também não resolveria, e isso foi medido, não argumentado. Com bundler 2.4.19 sob ruby 3.2.11 (2026-08-06), um Gemfile declarando ruby "2.4.0" junto de gem "jekyll", "4.3.3" — cujo gemspec exige ruby >= 2.5.0, verificado com gem specification jekyll -v 4.3.3 --remote required_ruby_version — produziu lockfile com exit 0 e gravou RUBY VERSION ruby 3.2.11p268, isto é, o interpretador que rodou a resolução, não o pin declarado. O bundler aplica required_ruby_version contra o interpretador que o executa (medido no sentido oposto: bundler 1.17.3 sob ruby 2.4.10 recusou a mesma gem com exit 6 e a mensagem jekyll (= 4.3.3) was resolved to 4.3.3, which depends on ruby (>= 2.5.0)), nunca contra uma versão de runtime declarada. Logo, “a resolução existir” não é prova de compatibilidade do pin declarado, e o resolved.lock deste card não é saída de resolvedor nenhum: é artefato mantido à mão, como a seção 7 diz.

O único subcomando do bundler que recusa aquele Gemfile é bundle check / bundle install: ambos saíram 18 com Your Ruby version is 3.2.11, but your Gemfile specified 2.4.0. Isso é um teste de igualdade entre o pin declarado e o interpretador que já está executando — dois runtimes —, não um julgamento ferramenta-vs-runtime; e ele só acontece antes da resolução, contra o interpretador real. Além disso a imagem selada do aceite (bash@sha256:ae4668c2560999e65e89532cd2ad1b6688bb23298189f0bd229ef80fa4bd0831) não tem ruby, nem bundle, nem gem — command -v para os três saiu 127 —, e o perfil roda com --network none. Nenhum resolvedor pode ser executado dentro do aceite, e um lockfile produzido fora dele só seria evidência para quem também confiasse na rede que ele precisou. Delegar, portanto, não é uma opção disponível: é outra promessa não verificada.

Uma matriz de mínimos escrita à mão seria enumeração — a mesma classe que custou três rodadas a este card — e teria o mesmo estatuto epistemológico do limite 4: uma afirmação sobre upstream que o aceite offline não pode verificar. Por isso a lacuna é declarada aqui em vez de simulada por uma tabela.

7. Fluxo de benção: três ações deliberadas e separadas

Acrescentar uma dependência nova exige três edições revisáveis, e nenhuma delas é implícita:

  1. editar o manifesto (Gemfile ou .aurumcode/Gemfile);
  2. escrever à mão um registro em resolved.lock com versão exata e digest distinto;
  3. --attest para o selo e depois --generate para o lock.

A ordem do passo 3 é obrigatória, e a rodada 5 a mediu. Desde que o lock passou a selar todo o diretório de fixtures declarado (limite/seção 4a), ele sela também read-paths.attested. Rodar --generate antes de --attest produz um lock que carrega o digest do selo antigo, e o aceite reprova com docs-tool-unlocked — não por capricho, mas porque o lock estaria afirmando bytes que já não existem. O caso disclosed_new_gem_fully_resolved executa a ordem correta; ele reprovou na ordem invertida durante esta rodada, e foi assim que a dependência de ordem apareceu.

Rodar apenas 1 e 3 — o “fluxo de regeneração recomendado” que as revisões cegas usaram para quebrar r3 — continua reprovando, agora com docs_tool_lock_incomplete. É exatamente o que o caso blockerR3_full_honest_flow_unpinned executa, e o que disclosed_new_gem_fully_resolved mostra passando quando as três ações acontecem.

8. Como rodar

# aceite oficial, container OCI pinado, sem rede
./.board/bin/oci-run --profile bootstrap-readonly-v1 --card AUR-364

# local, sem container
bash tests/acceptance/AUR-364.sh AC-001 ; echo "EXIT=$?"

# reselar os read_paths (ação separada e deliberada) — SEMPRE ANTES do lock
bash tests/acceptance/AUR-364.sh --attest > tests/bootstrap/locks/AUR-364/read-paths.attested

# regenerar o lock (não toca no selo nem na resolução; sela os bytes do selo)
bash tests/acceptance/AUR-364.sh --generate > .board/bootstrap/locks/docs.yml

# suíte de fixtures + os seis oráculos
bash tests/bootstrap/locks/AUR-364/verify-fixtures.sh

9. Diagnóstico

Sintoma Causa provável
docs_tool_lock_incomplete logo após editar um Gemfile falta o registro (manifesto, nome) em resolved.lock — o passo 2 do §7
docs-tool-unlocked sem ter mexido em Gemfile um read_path documental mudou de bytes; rode --attest depois de revisar o diff
docs-tool-unlocked no CI mas não local drift de outro trabalho em .github/workflows/documentation.yml; rebase e regenere lock + selo
invalid_lock_or_manifest num Gemfile que o Bundler aceita a linha é Ruby válido mas não é produção da allowlist (comentário no fim da linha, git:, source:, bloco source ... do) — deliberadamente estrito
runtime-resolution-inconsistent duas resoluções do mesmo nome divergem entre manifestos, ou o runtime da família não está resolvido/não tem versão exata. Não significa incompatibilidade: ver limite 6
docs-tool-unlocked logo após editar docs/specs/AUR-364.md o spec é selado no lock; rode --generate depois de revisar o diff
docs-tool-unlocked logo após editar cases.tsv, mutations.sh ou verify-fixtures.sh todo o diretório de fixtures declarado é selado (§4a); rode --generate
docs-tool-unlocked logo após um --attest --generate foi rodado antes do --attest; a ordem correta é selo primeiro, lock depois (§7)
docs-tool-unlocked logo após acrescentar ou renomear um fail <código> a tabela da seção 5 e o conjunto emitido divergiram; atualize a tabela
set-not-count-audit reprovando alguém acrescentou uma comparação aritmética sem etiqueta no aceite
no-pipefail-race reprovando alguém escreveu ... \| grep -q ... ou ... \| head ... no aceite; use here-string ou um comando só
nominal reprovando de forma intermitente procure por decisão tomada a partir do status de um pipeline (§3.1)