Skip to content

5. Ferramentas

English | Português (Brasil)

Tradução do original em inglês; quando os dois divergirem, vale o inglês.

← O contrato · Índice · Adoção e CI →

As ferramentas que viajam na release ficam em tools/. Elas dependem só de Python 3.12 ou mais e das versões fixadas, e com hash, em tools/requirements.txt (PyYAML e jsonschema, mais as dependências do jsonschema).

python3 -m venv .venv && .venv/bin/pip install --require-hashes -r tools/requirements.txt

Todo rc segue a mesma convenção: 0 passou, 1 algo reprovou, 2 a entrada está quebrada (arquivo ilegível, base ausente, tag inexistente). Um rc 2 nunca passa por verde.

Os exemplos abaixo rodam num repositório de demonstração com o kit em vendor/kg-ssot. Num clone do produto, troque vendor/kg-ssot/tools/ por tools/.

Língua da saída

Desde a 4.18.0, as ferramentas imprimem a saída humana em inglês por padrão, e em português do Brasil quando você pede. Cada ferramenta escolhe a língua do jeito do gettext e para na primeira variável definida:

  1. KG_SSOT_LANG, a escolha só para o KG-SSOT (pt-BR, pt_BR ou en);
  2. LANGUAGE, uma lista separada por dois-pontos (pt_BR:en), em que vence a primeira língua com catálogo;
  3. LC_ALL, depois LC_MESSAGES, depois LANG (pt_BR.UTF-8).

Uma variável vazia conta como não definida. C e POSIX são o inglês. Uma língua sem catálogo também: a variável definida decide do mesmo jeito, e as ferramentas não passam para a próxima. Um pt sozinho casa com pt_BR. A frase que ainda não tem tradução sai em inglês. O catálogo é locale/pt_BR/LC_MESSAGES/kg-ssot.po, e ele viaja na release e no vendor (também no --profile gate).

KG_SSOT_LANG=pt-BR python3 -I -B vendor/kg-ssot/tools/kg_validate.py docs/broken.kg.yaml
KG_SSOT_LANG=en python3 -I -B vendor/kg-ssot/tools/kg_validate.py docs/broken.kg.yaml

Dado não muda de língua. A estrutura do JSON e as chaves dele, os códigos (form.required.node.label), os códigos de saída, o lugar de uma ocorrência (nó C_RETRY_SAFE, aresta #3, arquivo) e a planilha TSV do kg_migrate.py ficam iguais em toda língua. Os campos message e hint de um veredito são texto para pessoas: seguem a língua na linha de comando. O servidor MCP fala inglês em qualquer locale, porque as descrições, os erros e os resultados dele são protocolo e prompt para o modelo. Como no gettext, o LANGUAGE só vale quando o locale efetivo não é o C, então LC_ALL=C força o inglês. Para um passo de CI não depender da língua, decida pelo rc, como mostra o capítulo 6.

kg_validate.py: o leitor de referência

Valida um ou mais arquivos contra o contrato vigente.

python3 -I -B vendor/kg-ssot/tools/kg_validate.py docs/example.kg.yaml docs/broken.kg.yaml
PASSA   docs/example.kg.yaml
REPROVA docs/broken.kg.yaml
  MUST   form.required.node.provenance ×1 · form.unknown-key.node.priority ×1
  SHOULD form.required.node.verified_against ×1 · integrity.untraced-decision ×1 · yaml.unquoted-date ×1
· 2 arquivos; 1 passam no MUST; 1 com aviso SHOULD (kg-contract-v4.schema.json + kg-contract-v4.should.schema.json)

rc 1. Para ver de qual nó sai cada código, use --where:

python3 -I -B vendor/kg-ssot/tools/kg_validate.py --where docs/broken.kg.yaml
REPROVA docs/broken.kg.yaml
  MUST   form.required.node.provenance ×1 · form.unknown-key.node.priority ×1
  SHOULD form.required.node.verified_against ×1 · integrity.untraced-decision ×1 · yaml.unquoted-date ×1
    MUST   form.required.node.provenance  nó C_RETRY_SAFE
           → Acrescente provenance com source, locator e method; sem fonte recuperável, mude o status para unverifiable.
    MUST   form.unknown-key.node.priority  nó C_RETRY_SAFE
           → Renomeie a chave com o prefixo x_ (extensão que os leitores ignoram) ou remova-a.
    SHOULD form.required.node.verified_against  nó C_RETRY_SAFE
    SHOULD integrity.untraced-decision  nó D_RETRY_POLICY
    SHOULD yaml.unquoted-date  arquivo
· 1 arquivos; 0 passam no MUST; 1 com aviso SHOULD (kg-contract-v4.schema.json + kg-contract-v4.should.schema.json)

Opções úteis:

Opção Efeito
--where Uma linha por ocorrência, com o nó ou a aresta; numa ocorrência MUST, a correção vem logo abaixo
--corpus DIR Confere a ponta externa de cada entrada de external_edges contra os .kg.yaml e .kg.json que o git rastreia sob DIR (fora de um repositório git, todos)
--schema F Valida contra outro schema MUST (o schema SHOULD vizinho vem junto, se existir)
--format json O veredito estruturado em stdout (ver abaixo), para ferramentas e servidores MCP
--limit N Com --format json, quantas ocorrências por arquivo (default 50); o resumo conta todas
- no lugar do arquivo Lê o grafo do stdin, sem arquivo temporário; --stdin-format json lê um grafo .kg.json (default yaml)
--should-schema F Com --schema, o schema SHOULD que acompanha (no lugar do vizinho)
--due AAAA-MM-DD No lugar do veredito, o relatório de vencimento na data (desde a 4.15.0): cada nó com reverify_every cujo verified_at somado à cadência cai na data ou antes, cada nó que tem cadência e não tem carimbo, e cada nó que só tem reverify_on. Desde a 4.19.0 lista também os perfis canônicos com cadência: cada sinônimo de um x_term que não está aposentado, o x_mirror (synced_at mais resync_every) e o x_doc_cycle (checked_at mais recheck_every), com subject e detail no JSON. O rc é 0 (ou 2 com entrada quebrada): o vencimento nunca reprova, e o relatório não julga o grafo (o gate segue sendo o kg_gate.py). Um arquivo que não parseia sai como NÃO LIDO; num job agendado, olhe essa lista, porque o rc não a acusa. A cadência fora da forma ou zerada fica fora do relatório (o aviso SHOULD a acusa), e o vencimento depois do ano 9999 também. Com --format json, a saída usa o formato kg-ssot.due/1 (spec/due.schema.json)

As opções --json OUT, --spike, --git, --rev e --list são do instrumento de medição que deu origem ao contrato (o "spike"). Elas gravam e reproduzem um formato legado, congelado, e não fazem parte da interface estável. Para ferramentas, use --format json.

Com external_edges e sem --corpus, o leitor avisa que não conferiu a ponta externa:

PASSA   docs/decision.kg.yaml
· 1 arquivos; 1 passam no MUST; 0 com aviso SHOULD (kg-contract-v4.schema.json + kg-contract-v4.should.schema.json)
· 1 arquivo(s) com external_edges: a ponta externa NÃO foi conferida (rode com --corpus <raiz do repo>)

O veredito estruturado (--format json)

Se você chama o leitor por programa (um job de CI, um servidor MCP, um chat), o --format json devolve o veredito num formato versionado, kg-ssot.verdict/1, com JSON Schema em spec/verdict.schema.json. Saída real de um caso da suíte:

python3 -I -B vendor/kg-ssot/tools/kg_validate.py --format json \
  vendor/kg-ssot/spec/conformance/fixtures/latest/form/minimal/bad-node-missing-label.kg.yaml
{
 "schema_version": "kg-ssot.verdict/1",
 "contract": {
  "must": "spec/kg-contract-v4.schema.json",
  "should": "spec/kg-contract-v4.should.schema.json",
  "release": "kg-ssot-v4.39.0",
  "sha256": { "must": "6a05cb1e…", "should": "04327d29…" }
 },
 "results": [
  {
   "source": "…/bad-node-missing-label.kg.yaml",
   "verdict": "FAIL",
   "summary": { "MUST": { "form.required.node.label": 1 }, "SHOULD": {} },
   "occurrences": [
    {
     "level": "MUST",
     "code": "form.required.node.label",
     "place": "nó Q_A",
     "path": "nodes[0]",
     "line": 5,
     "message": "Falta o campo obrigatório «label» no nó.",
     "hint": "Acrescente label: a afirmação curta do nó, até 280 caracteres."
    }
   ],
   "total": 1,
   "truncated": false,
   "external_checked": true
  }
 ]
}

(Os dois sha256 e o caminho estão abreviados aqui, e alguns objetos ficam numa linha só.)

  • O que é estável: code é o vocabulário da suíte e do gate, e é por ele que uma ferramenta decide. message e hint são texto para pessoas: seguem a língua da saída e podem mudar.
  • O lugar da ocorrência: path aponta no documento (nodes[7].provenance.method) e line é a linha no arquivo, no .kg.yaml e no .kg.json, para quem edita corrigir o ponto certo sem reescrever o grafo.
  • O corte: summary conta todas as ocorrências. occurrences traz até --limit, e truncated e total dizem se a lista foi cortada. Assim um grafo grande não enche o contexto de um modelo.
  • O contrato que julgou: contract traz o caminho, o sha256 de cada schema e a release, para quem precisa provar qual contrato julgou.
  • O rc: 0 quando todos os arquivos passam, 1 quando algum reprova (um arquivo que não parseia reprova com parse.*, como na suíte) e 2 quando a entrada está quebrada (arquivo ilegível, opção inválida).
  • O desempenho: com a libyaml instalada (o caso comum do PyYAML), o parse usa o parser em C, com o mesmo perfil YAML 1.2. Ele só cai no parser puro, mais lento, nos raros textos em que os dois podem divergir (tab, %YAML, escape de surrogate ou \U, BOM fora do início).

kg_conformance.py: a suíte

Roda a suíte de conformidade contra o leitor de referência. Primeiro o metaschema se auto-testa, depois cada manifesto é validado, depois cada caso roda.

python3 -I tools/kg_conformance.py

As últimas linhas:

—
latest: 512/512 (reprova)
optional: 10/24 (só reporta)

rc 0. latest/ é o contrato vigente, e um caso que falha ali reprova a rodada (rc 1). optional/ é comportamento que um leitor pode implementar (hoje, a camada semântica de frescor e domínio, que o leitor de referência não implementa) e nunca muda o rc. rc 2 quer dizer que a própria suíte está quebrada: manifesto inválido, arquivo fora de manifesto, pasta desconhecida.

kg_gate.py: a catraca por grafo

O gate é para o CI de um repositório que guarda grafos. Ele mede todos os .kg.yaml que o git rastreia e compara o resultado com uma linha de base (a pasta .kg-ssot/gate/). A regra: nada piora. Um grafo que já reprovava fica registrado como dívida herdada; um grafo novo tem de passar.

Sem base, o gate se recusa a rodar:

GATE QUEBRADO  linha de base ausente (.kg-ssot/gate): grave a primeira com --update

rc 2. A primeira base:

python3 -I -B vendor/kg-ssot/tools/kg_gate.py --update
  MUST   docs/broken.kg.yaml (herdado): form.required.node.provenance · form.unknown-key.node.priority
  SHOULD form.required.node.verified_against: 1 grafo
  SHOULD integrity.untraced-decision: 1 grafo
  SHOULD yaml.unquoted-date: 1 grafo
linha de base gravada: .kg-ssot/gate (2 grafos, 1 na dívida herdada)

Um grafo novo que reprova, depois de git add:

✗ PIOROU  docs/new.kg.yaml falha no MUST: form.required.node.provenance, form.unknown-key.node.priority
✗ PIOROU  debt.form.required.node.verified_against: 1 → 2
✗ PIOROU  debt.integrity.untraced-decision: 1 → 2
✗ PIOROU  debt.yaml.unquoted-date: 1 → 2
· 3 grafos; 1 passam no MUST; 1 na dívida herdada
  MUST   docs/broken.kg.yaml (herdado): form.required.node.provenance · form.unknown-key.node.priority
  MUST   docs/new.kg.yaml (novo): form.required.node.provenance · form.unknown-key.node.priority
  SHOULD form.required.node.verified_against: 2 grafos
  SHOULD integrity.untraced-decision: 2 grafos
  SHOULD yaml.unquoted-date: 2 grafos

rc 1. E quando a dívida é paga (o grafo herdado consertado):

↑ melhorou docs/broken.kg.yaml: passa no MUST (trave o ganho: kg_gate.py --update)
↑ melhorou debt.form.required.node.verified_against: 1 → 0 (trave o ganho: kg_gate.py --update)
↑ melhorou debt.integrity.untraced-decision: 1 → 0 (trave o ganho: kg_gate.py --update)
↑ melhorou debt.yaml.unquoted-date: 1 → 0 (trave o ganho: kg_gate.py --update)
· 2 grafos; 2 passam no MUST; 0 na dívida herdada

rc 0, com o pedido de travar o ganho. Rode --update no mesmo PR para a dívida não voltar.

Opção Efeito
--update Grava a base. Se algo piorou, recusa, a menos que você passe --accept-regression
--accept-regression "<motivo>" Aceita uma piora; o motivo fica gravado na base
--exclude GLOB Tira caminhos do corpus (repetível). */fixtures/*, docs/materials/* e o vendor do KG-SSOT (qualquer pasta com o carimbo .kg-ssot-version, desde a 4.19.1) já saem por padrão; o --no-default-excludes os traz de volta
--granularity node Com --update: conta a dívida SHOULD por ocorrência, não por grafo
--no-default-excludes Desliga os excluídos padrão (e aí as fixtures do vendor entram no corpus)
--repo DIR · --baseline ARQ Outro repositório, outra base

kg_vendor.py: trazer e conferir a release

O adotante nunca copia o contrato à mão. A ferramenta de vendor lê uma tag com git archive e grava um carimbo com o sha256 de cada arquivo.

Rode da raiz do adotante, com um clone do repositório público em ../kg-ssot. As saídas abaixo são de execuções reais da ferramenta da 4.18.0 (o update e o check) com a tag kg-ssot-v4.17.2. As ferramentas das releases anteriores à 4.18.0 imprimem em português. Numa release mais nova, a tag, o commit e a contagem de arquivos mudam:

git clone https://github.com/marciocar/kg-ssot ../kg-ssot
python3 -I -B ../kg-ssot/tools/kg_vendor.py update --tag kg-ssot-v4.17.2 --dest vendor/kg-ssot
vendor atualizado: vendor/kg-ssot ← kg-ssot-v4.17.2 (d5a6cab1cb09), 504 arquivos

O update grava também vendor/kg-ssot/.gitattributes com * -text. Um atributo aninhado vence o do repositório, e assim a regra de fim de linha do adotante (*.json text eol=lf, core.autocrlf) não reescreve no índice os bytes da release. A suíte tem fixtures com CRLF de propósito, que sem isso chegariam alteradas ao CI.

O check recalcula os sha256 e compara com o carimbo. Um arquivo editado, apagado ou sobrando (inclusive bytecode num __pycache__) dá rc 1, e o check também confere o .gitattributes do vendor. Com --index, o check confere os blobs do índice do git, que é o que o commit vai levar, e não a árvore de trabalho. Rode-o depois do git add vendor/kg-ssot: ele pega, antes do commit, uma normalização que a árvore de trabalho esconde.

python3 -I -B vendor/kg-ssot/tools/kg_vendor.py check --dest vendor/kg-ssot
✓ vendor íntegro: vendor/kg-ssot = kg-ssot-v4.17.2 (d5a6cab1cb09), 504 arquivos
Opção Efeito
--source REPO O repositório (caminho ou URL, como https://github.com/marciocar/kg-ssot) que tem a tag. Obrigatório quando você roda de dentro de um vendor
--dest DIR Onde fica o vendor (default vendor/kg-ssot)
--profile gate Traz só o mínimo para rodar o gate e o check (17 arquivos desde a 4.26.0, sem a suíte); --profile full volta à release inteira
--force Substitui um vendor editado localmente
--index (no check) Confere o índice do git em vez da árvore de trabalho
--ref COMMIT (no update) A bancada antes da tag (4.24): traz um commit que ainda não é release, de um repositório a que você chega. O carimbo diz unreleased
--allow-unreleased (no check e no kg_gate.py) Aceita um vendor de bancada. Sem ele, o check e o gate reprovam esse vendor (e o kg_gate.py nunca grava a base a partir de um)

Por que -B? Os scripts do kit não gravam bytecode, mas uma ferramenta sua que importa um módulo do kit grava o .pyc dentro do vendor, e o próximo check reprova. Rode tudo com python3 -I -B, ou com PYTHONDONTWRITEBYTECODE=1.

kg_store.py: o armazém que só grava o que passa

Se você grava grafo por programa (um servidor de escrita, um chat, um pipeline que gera o grafo de um caso), o kg_store.py é uma biblioteca, com um CLI por cima, que só escreve depois do veredito PASS do contrato. Há três armazéns com a mesma interface (read, list, write, delete, history). O formato vem do nome: .kg.yaml é julgado no perfil YAML e .kg.json no perfil JSON do contrato (capítulo 3):

  • DiskStore: só a cabeça em disco, sem histórico;
  • GitStore: um commit por gravação ou remoção. O git roda isolado na raiz do armazém e nunca sobe para um repositório de fora. O autor vem de quem grava (author="Name <email>"), e o committer é uma identidade fixa do serviço;
  • VersionedStore: a cabeça em disco mais um registro de eventos e as versões no diretório oculto .kg-store/, sem git.

Pelo pacote PyPI, é from kg_ssot import kg_store. Para importar do vendor, ponha vendor/kg-ssot/tools no sys.path e rode sem gravar bytecode, como no capítulo 6:

import kg_store

store = kg_store.GitStore("areas/ana", init=True)
r = store.write("graphs/case.kg.yaml", text, message="first graph", author="Ana <ana@example.org>",
                expected_sha256=kg_store.NEW)
if not r["written"]:
    print(r["reason"], r["verdict"])   # "gate", "conflict", "missing", or "not_judged"

O gate julga mais que o candidato. A ponta externa de cada entrada de external_edges é conferida contra o corpus do armazém (no GitStore, também os grafos rastreados em pasta com ponto, desde a 4.27.0), já com o candidato no lugar. Cada grafo que aponta para o nome gravado é julgado de novo, porque trocar ou apagar um grafo pode deixar a aresta externa de outro sem ponta. O armazém só grava com PASS em todos.

Desde a 4.20.0, um objeto de armazém guarda, na memória, o veredito de cada grafo que já julgou, pela chave de tudo de que o veredito depende: o texto e o formato dele, os dois schemas, o limit, a língua das mensagens, se há corpus e, para cada ponta externa, se o id citado existe. Um grafo que aponta para o nome gravado e não mudou não é julgado de novo enquanto as pontas dele seguem resolvidas, e um processo longo (o servidor MCP, um app que mantém o armazém aberto) paga o custo uma vez. Gravar o mesmo texto de novo também não o rejulga pelo contrato; o gate externo (--gate-cmd), se houver, roda em toda gravação. Um processo novo começa com o cache vazio, e a primeira gravação paga o custo inteiro.

O corpus é o mesmo que o gate do repositório veria. No GitStore, isso quer dizer só o rastreado: um .kg.yaml que está no disco sem commit não conta, nem na lista, nem no julgamento, nem na trava (para a trava, o nome está livre, e a gravação substitui o arquivo). Desde a 4.27.0 (contrato v4.15), o julgamento do GitStore lê também os grafos rastreados em pasta com ponto (.github/), como o gate: a ponta externa ali resolve, e uma mudança ali pode barrar uma gravação que a deixe sem ponta. O armazém continua sem escrever ali, e o list(), o read() e as ferramentas kg_query e kg_terms do servidor MCP veem só os nomes do próprio armazém. O DiskStore e o VersionedStore julgam só os nomes do armazém (a pasta com ponto fora do git não é lida), e recusam uma raiz dentro de um repositório git, onde o gate leria o rastreado e o armazém leria o disco. E a cabeça só fica trocada com o registro feito: se o commit ou o evento falha, ela volta ao que era, e a exceção chega a quem chamou.

Para regenerar grafos que se citam, use write_many (desde a 4.14.0). Um a um, a troca do primeiro grafo deixaria a aresta externa do segundo sem ponta, e o rejulgamento barraria. O conjunto inteiro, inclusive com remoções (texto None), é julgado de uma vez, com todas as mudanças no lugar, e entra num registro só (um commit, ou um bloco de eventos), ou nada entra:

r = store.write_many([("case.kg.yaml", case), ("crossing.kg.json", crossing), ("old.kg.yaml", None)],
                     message="regenerate the case", expected={"case.kg.yaml": sha_read})
if not r["written"]:
    print(r["reason"], [x["reason"] for x in r["results"]])

Em conflict e missing, o nome que barrou o conjunto leva o motivo, e os outros voltam como skipped. Em gate e not_judged, o julgamento é do conjunto inteiro, e todos os nomes voltam com esse motivo. O gate externo (--gate-cmd), se houver, julga cada mudança isolada, como no write. Nomes do conjunto que só diferem pela caixa são recusados, como no armazém.

O CLI tem um subcomando por operação, com a saída em JSON no stdout:

kg_store.py --backend disk|git|versioned --root DIR [--init] [--gate-cmd CMD [--gate-timeout S]] list
kg_store.py ... read NAME [--ref REF]
kg_store.py ... write NAME [FILE|-] [--message M] [--author "Name <email>"] [--expected-sha256 S|new]
kg_store.py ... delete NAME [--message M] [--author A] [--expected-sha256 S]
kg_store.py ... write-many SET.json [--message M] [--author A]
kg_store.py ... history NAME

Pelo CLI, um grafo que passa e um que reprova:

python3 -I -B vendor/kg-ssot/tools/kg_store.py --backend git --root areas/ana --init \
  write graphs/valid-minimal.kg.yaml vendor/kg-ssot/spec/conformance/fixtures/latest/form/minimal/valid-minimal.kg.yaml \
  --author "Ana <ana@example.org>" --message "first graph"
{
 "written": true,
 "reason": null,
 "name": "graphs/valid-minimal.kg.yaml",
 "sha256": "b725f37669f2e5979ed8c02eaae3c59ba80a490177e2f28c0cb8a580cf51cfd7",
 "ref": "2c6e97cc668be073020246d673228ec11dddb9aa",
 "verdict": { … },
 "error": null,
 "unchanged": false
}
python3 -I -B vendor/kg-ssot/tools/kg_store.py --backend git --root areas/ana \
  write graphs/x.kg.yaml vendor/kg-ssot/spec/conformance/fixtures/latest/form/minimal/bad-node-missing-label.kg.yaml
{
 "written": false,
 "reason": "gate",
 "name": "graphs/x.kg.yaml",
 ...
 "verdict": { "results": [ { "source": "graphs/x.kg.yaml", "verdict": "FAIL",
              "summary": { "MUST": { "form.required.node.label": 1 }, "SHOULD": {} }, ... } ] }
}

(O verdict está abreviado aqui; ele é o documento kg-ssot.verdict/1 inteiro.)

  • reason: é null quando gravou. "gate" quer dizer que o contrato reprovou o grafo, e o verdict diz onde. "conflict" quer dizer que o expected_sha256 não bate com a cabeça. "missing" aparece quando você remove o que não existe. "not_judged" aparece quando o julgamento falhou ou saiu fora de spec/verdict.schema.json: nunca vira PASS nem "gate", para quem chama distinguir "reprovou" de "não deu para julgar". Com um gate externo, "gate" com error: "reprovado pelo gate externo" quer dizer que o contrato aprovou e o gate de fora reprovou o grafo; o verdict é o do gate externo.
  • A trava otimista: expected_sha256 é o sha256 da cabeça que quem grava leu, ou NEW (no CLI, new) para exigir que o nome ainda não exista. Gravar o mesmo texto da cabeça devolve written com unchanged e sem commit.
  • O caminho: o nome é relativo, termina em .kg.yaml ou .kg.json e não tem .. nem segmento oculto. Para o armazém ser copiável entre sistemas, ele recusa criar um nome com segmento reservado do Windows (CON, NUL, COM1...) ou que termina em ponto (o que já existe segue funcionando), e nenhuma operação aceita um nome que só difere pela caixa de um que já está no armazém. A raiz, as pastas e o arquivo não podem ser link:
  • no macOS e no Linux (POSIX), tudo se abre por dir_fd com O_NOFOLLOW, e trocar uma pasta no meio da operação não desvia nada;
  • no Windows, que não tem dir_fd, cada passo do caminho é conferido por lstat (link simbólico, junção e ponto de montagem são recusados; o reparse point que não redireciona o nome, como o arquivo do OneDrive sob demanda, passa), o arquivo aberto tem de ser o mesmo que o lstat viu, e o caminho é conferido de novo depois da leitura e, na gravação, antes da troca final. É mais fraco: quem pode escrever dentro da raiz consegue trocar uma pasta entre a conferência e o uso. Num sistema de arquivos sem id de arquivo (o FAT de um pendrive), a comparação do arquivo aberto não prova nada, e só a recusa de link sobra. Num armazém de uma pessoa só, na pasta dela, isso não muda nada; para um armazém compartilhado, prefira POSIX.
  • O histórico: history(name) lista as operações, da mais nova para a mais antiga, com ref, op (write ou delete), autor, data e mensagem. read(name, ref=...) lê uma versão anterior: o sha do commit no git, o seq do evento no versionado.
  • O git isolado: o GitStore não lê a config do sistema nem a do usuário (sem hooks, sem gpg, sem autocrlf, sem arquivo de exclusão), então o que ele guarda é byte a byte o texto aprovado, com o sha256 do resultado.
  • O gate externo (--gate-cmd): é o gancho por onde um adotante liga um segundo gate, o dele, sem o produto depender desse gate. O comando roda depois de o contrato aprovar, em cada gravação e cada remoção, e o armazém só grava com PASS dos dois. Um grafo que o contrato reprova nunca chega ao comando. O gate que o kg_mcp.py roda recebe KG_SSOT_LANG=en, porque o veredito dele vai ao modelo; pelo kg_store.py, ele herda a sua língua.
  • A entrada: o texto candidato vai pelo stdin em UTF-8 (vazio na remoção); leia-o como bytes e decodifique, para não depender da página de código do sistema. O nome e a operação vão pelo ambiente, como KG_GATE_NAME e KG_GATE_OP (write ou delete). O comando herda o resto do ambiente do processo, menos a variável do segredo do kg_mcp (--secret-env).
  • A saída: um documento kg-ssot.verdict/1 válido por spec/verdict.schema.json, com ao menos um resultado, rc 0 quando tudo passa e rc 1 quando algo reprova, e no máximo 16 MiB (o teto vale enquanto ele roda). O stderr do gate é descartado: para diagnosticar, rode o comando à mão com o mesmo stdin e o mesmo ambiente. O código de uma ocorrência tem de começar por uma camada do contrato (parse., form., integrity. ou yaml.); um gate de fora usa, por exemplo, integrity.external.<nome>.
  • Fail-closed: comando que não roda, que passa de --gate-timeout (padrão 60 s, no máximo 86.400), que sai sem JSON, com saída grande demais, com JSON fora do schema ou sem resultado, com rc fora de 0 e 1 ou com rc que não bate com o veredito dá "not_judged", nunca PASS, e nada é gravado. No tempo esgotado, numa interrupção (Ctrl+C), num SIGTERM ao processo ou com a saída grande demais, morrem o comando e os processos que ele abriu. No Windows, matar o processo à força (o "Finalizar tarefa") não deixa tratador nenhum agir, e o gate que estiver rodando pode ficar. No macOS e no Linux, um processo que o gate deixa rodando depois de terminar bem também é morto (o grupo de processos dele); no Windows, não, então o gate de um adotante não deve deixar nada para trás. --gate-cmd "" dá rc 2, para uma variável vazia nunca subir um servidor sem o gate dele; --gate-timeout sem --gate-cmd e, no kg_mcp.py, --gate-cmd com --read-only (o gate nunca rodaria) também dão rc 2.
  • A trava: o gate roda sob a trava da gravação. Um gate lento segura as outras gravações do mesmo armazém até o --gate-timeout; no stdio, segura o servidor.
  • O mesmo texto: gravar de novo o texto da última versão também passa pelo gate. Se ele reprovar agora, a resposta é "gate", mesmo sem nada a mudar.
  • Sem shell: o comando é uma lista de argumentos, nunca uma linha de shell. No macOS e no Linux, a linha segue as regras do shlex (a barra invertida escapa; ponha o caminho entre aspas). No Windows, as aspas duplas agrupam e a barra invertida fica como está, como no comando "C:\Program Files\Python\python.exe" C:\gates\x.py. As aspas valem em volta do argumento inteiro: --opt="a b" vira dois argumentos, e aspas simples não agrupam. O texto do grafo só chega pelo stdin, então $(...) ou ; nele não executam nada.
  • Pela biblioteca: open_store(..., gate=CommandGate(argv, timeout)), ou qualquer chamável (name, text, op) -> (verdict, error). Um chamável que levanta exceção também dá "not_judged".

Um gate de exemplo, só com a biblioteca padrão, que reprova todo texto com a palavra FORBIDDEN:

import json, os, sys
bad = "FORBIDDEN" in sys.stdin.buffer.read().decode("utf-8")
occ = [{"level": "MUST", "code": "integrity.external.forbidden-word", "place": "arquivo", "path": "",
        "line": None, "message": "the text has a forbidden word"}] if bad else []
print(json.dumps({"schema_version": "kg-ssot.verdict/1",
                  "contract": {"must": None, "should": None, "release": None, "sha256": {"must": None, "should": None}},
                  "results": [{"source": os.environ["KG_GATE_NAME"], "verdict": "FAIL" if bad else "PASS",
                               "summary": {"MUST": {occ[0]["code"]: 1} if bad else {}, "SHOULD": {}},
                               "occurrences": occ, "total": len(occ), "truncated": False,
                               "external_checked": False}]}))
sys.exit(1 if bad else 0)

python3 -I tools/kg_store.py --backend versioned --root areas/ana --init \
  --gate-cmd "python3 -I gates/forbidden.py" write graphs/a.kg.yaml graph.kg.yaml
- O que fica com o adotante, de propósito: autenticação, identidade, área por usuário (uma raiz por usuário), tamanho máximo, número de grafos, timeout e a trava entre processos onde não há flock nem msvcrt. - O rc do CLI: 0 gravou ou leu, 1 recusado (gate, conflict, missing, nome inexistente) e 2 entrada quebrada ou not_judged.

kg_mcp.py: o servidor MCP de referência

Para dar a um modelo o contrato e o armazém sem escrever um servidor, o kg_mcp.py fala o Model Context Protocol por stdio (uma mensagem JSON-RPC por linha) ou por HTTP (POST /mcp, resposta JSON, sem stream). Ele usa só a biblioteca padrão, o leitor e o armazém, e é um adaptador: o produto não depende dele, e ele não depende de nenhum cliente específico.

Ferramenta O que faz
kg_validate Julga um texto .kg.yaml sem gravar e devolve o kg-ssot.verdict/1 (com o armazém como corpus, se há armazém)
kg_write Grava pelo armazém, só com PASS; devolve o resultado do kg_store com o veredito
kg_delete Remove pelo armazém, se nenhum grafo que aponta para o nome reprovar sem ele
kg_get Lê um grafo, ou uma versão anterior com ref
kg_list Lista os grafos do armazém
kg_history As operações de um grafo, da mais nova para a mais antiga
kg_query A consulta e a projeção do kg_query.py sobre o armazém: filtros, a fila dos abertos, a vizinhança e fields, paginada por cursor; desde a 4.43.0, também due, as_of, has, missing e chain
kg_terms Desde a 4.19.0: resolve um termo de domínio pelo nome preferido ou por um sinônimo (o perfil x_term), com a fonte, a versão e a data de conferência de cada sinônimo e o termo que substitui um termo substituído; sem text, lista os termos
kg_schema_help O contrato vigente, para um modelo: os schemas MUST e SHOULD inteiros, um exemplo mínimo que passa, as regras de integridade e a correção dos códigos mais comuns

Desde a 4.17.2, cada ferramenta anuncia no tools/list as anotações do MCP: as de leitura (kg_validate, kg_get, kg_list, kg_query, kg_terms, kg_history, kg_schema_help) com readOnlyHint e idempotentHint, e o kg_write e o kg_delete com destructiveHint; todas com openWorldHint: false, porque o armazém é local. São dicas para o cliente pedir ou dispensar a confirmação; o gate do armazém segue decidindo o que é gravado.

Sem --root, só kg_validate e kg_schema_help aparecem. Com --read-only, kg_write e kg_delete somem da lista.

Com --store-optional, uma raiz que não abre (um arquivo no lugar da pasta, uma pasta sem permissão) não derruba o servidor. Ele sobe só com kg_validate e kg_schema_help e diz o motivo ao modelo, nas instruções, na descrição das duas ferramentas e num campo store do kg_schema_help. Assim o modelo conta ao usuário por que não há kg_write, em vez de supor um modo só leitura. Sem a opção, a raiz que não abre é rc 2, com a mensagem no stderr.

Uma configuração de cliente por stdio, com um armazém versionado fora do repositório:

{
  "mcpServers": {
    "kg-ssot": {
      "command": "python3",
      "args": ["-I", "-B", "vendor/kg-ssot/tools/kg_mcp.py",
               "--root", "/path/to/store", "--backend", "versioned", "--init",
               "--author", "Assistant <assistant@example.org>"]
    }
  }
}
Opção Efeito
--root DIR O armazém. Sem ele, só kg_validate e kg_schema_help
--backend disk\|git\|versioned O tipo de armazém (default disk)
--init Cria a raiz se não existir
--author "Name <email>" Quem assina as gravações
--read-only Tira kg_write e kg_delete
--http PORT · --host H · --allow-remote HTTP no lugar do stdio; fora do loopback, só com --allow-remote, que exige --secret-env
--secret-env VAR · --secret-header H O segredo compartilhado, lido da variável VAR, que cada requisição HTTP traz no header H (padrão X-Api-Key), comparado por hmac
--identity-header H O perfil "atrás de proxy": a identidade de cada requisição vem do header H (um e-mail), com área e autor próprios; exige --secret-env
--max-bytes N O tamanho máximo de um grafo (default 2.000.000)
--max-graphs N Grafos por armazém, ou por área no perfil; além disso, um nome novo é recusado
--max-concurrent N Requisições HTTP ao mesmo tempo; além disso, 503
--store-optional Uma raiz que não abre não derruba o servidor: ele sobe sem armazém e diz o motivo ao modelo
--gate-cmd CMD · --gate-timeout S Um gate externo depois do contrato, em toda gravação e remoção, também por área no perfil (veja o kg_store.py)

Uma gravação recusada, como o modelo a recebe (o structuredContent, abreviado):

{
 "written": false,
 "reason": "gate",
 "name": "graphs/x.kg.yaml",
 "verdict": { "results": [ { "verdict": "FAIL",
   "occurrences": [ { "code": "form.required.node.label", "place": "nó Q_A", "path": "nodes[0]", "line": 5,
                      "message": "Falta o campo obrigatório «label» no nó.",
                      "hint": "Acrescente label: a afirmação curta do nó, até 280 caracteres." } ] } ] },
 ...
}

A resposta de tools/call traz o mesmo objeto em structuredContent e, como texto JSON, em content. Uma gravação recusada ou um nome inexistente vem com isError: true, para o modelo corrigir e tentar de novo.

  • Quem assina: o autor das gravações é o --author do servidor ou, no perfil "atrás de proxy", a identidade autenticada da requisição. Nunca é um argumento da ferramenta: o modelo não escolhe quem assina.
  • Os limites: --max-bytes (padrão 2.000.000) limita o texto de um grafo. Os argumentos são conferidos contra o inputSchema que o tools/list anuncia: tipo errado, campo obrigatório ausente e campo desconhecido voltam como isError. Uma falha interna vira o erro JSON-RPC -32603, e o processo segue atendendo.
  • O leitor por trás: um documento que abre mais de 1.000 coleções de uma vez (em flow, em bloco ou por indentação), ou cujos aliases expandem para mais que o dobro do tamanho do texto, reprova com parse.yaml-error antes de ser construído. Sem esse teto, uma mensagem de 60 KB derrubava o processo. Colchete dentro de texto não conta.
  • O protocolo: uma mensagem por vez; lote JSON-RPC é recusado (saiu do MCP em 2025-06-18), e o id tem de ser texto ou inteiro. O stdio lê e escreve UTF-8 como bytes, independente do locale, e a resposta sai em JSON ASCII.
  • O HTTP: escuta em 127.0.0.1 por padrão. Recusa Origin cujo host não seja de loopback (contra DNS rebinding), MCP-Protocol-Version desconhecida (400) e corpo sem Content-Length. Não há TLS: o servidor só sai do loopback com --allow-remote, que exige --secret-env, para rodar numa rede privada atrás de um proxy com TLS.
  • O perfil "atrás de proxy" (--identity-header): é o modo para um cliente com muitos usuários, como o LibreChat, que manda o usuário logado num header.
  • Recusa por padrão: toda requisição traz a identidade (um e-mail) e o segredo compartilhado. Sem identidade, com identidade vazia (o LibreChat troca um placeholder ausente por texto vazio) ou malformada, ou com o segredo errado, a resposta é 401, antes de qualquer ferramenta rodar.
  • Área e autor: cada identidade grava na própria área sob --root, num diretório u-<nome>-<hash>, e não enxerga a dos outros. As gravações dela são assinadas na forma parte-local <e-mail>. O e-mail não distingue caixa: Ana@X.org e ana@x.org são a mesma identidade.
  • Por requisição: identidade, área e autor viajam num contexto de cada requisição, e nunca num atributo do servidor, que é um só entre as threads. Guardá-los no servidor deixaria uma requisição concorrente gravar na área de outra, a classe do CVE-2026-25536 do SDK TypeScript. Um teste determinístico confere que nenhuma requisição deixa ou troca estado no servidor, e outro teste, com duas identidades em paralelo, exercita o isolamento de ponta a ponta. Um header de identidade ou de segredo repetido é recusado, para um proxy que acrescente o header em vez de substituí-lo não deixar o cliente escolher a identidade.
  • O modelo de confiança: quem tem o segredo afirma qualquer identidade. O segredo autentica o proxy (o LibreChat), e é o proxy que autentica o usuário. Por isso o segredo nunca vai a um cliente final, e o servidor fica numa rede que só o proxy alcança.
  • Os limites: --max-graphs vale por área e é contado sob a trava da gravação, então gravações paralelas não passam juntas dele. initialize, ping e as notificações não abrem nem criam área, e com --read-only a área que ainda não existe não é criada. O número de áreas não tem limite: quem tem o segredo cria uma por identidade nova (o modelo de confiança acima), e o servidor guarda as áreas abertas em memória enquanto roda.
  • A raiz das áreas: é conferida ao subir. Valor vazio, raiz inexistente sem --init, arquivo ou link dá rc 2, a menos que você passe --store-optional, como na raiz fora do perfil.
  • O envelope untrusted_data: kg_get, kg_list, kg_history, kg_query e kg_terms devolvem {"untrusted_data": ..., "notice": ...}, para o modelo tratar o texto gravado por outros como dado, nunca como instrução.
  • O que o perfil não é: é "atrás de proxy", numa rede privada. Não é o perfil OAuth da especificação MCP, que espera até haver uma matriz medida de clientes.
  • A imagem OCI: ghcr.io/marciocar/kg-ssot:<version> traz o kg-mcp pronto, para rodar como serviço ao lado de um cliente sem Python, como o LibreChat. Ela é construída do mesmo wheel do PyPI, com as dependências fixadas por hash e a base fixada por digest (packaging/oci/Dockerfile), roda com um usuário sem privilégio (uid 10001) e guarda o armazém no volume /data. Leva o label io.modelcontextprotocol.server.name, que o MCP Registry usa para conferir a posse. O workflow do espelho a publica atrás de aprovação, com a atestação de proveniência (gh attestation verify oci://ghcr.io/marciocar/kg-ssot:<version> --repo marciocar/kg-ssot).
  • Por stdio: docker run -i --rm ghcr.io/marciocar/kg-ssot:<version> só julga. Com -v kg-data:/data e --root /data/graphs --init, grava.
  • Ao lado do LibreChat: o perfil "atrás de proxy", na rede do compose e sem porta publicada no host:

    # docker-compose.override.yml
    services:
      kg-ssot:
        image: ghcr.io/marciocar/kg-ssot:<version>
        command: ["--http", "8080", "--host", "0.0.0.0", "--allow-remote",
                  "--identity-header", "X-User-Email", "--secret-env", "KG_MCP_SECRET",
                  "--root", "/data/areas", "--init", "--backend", "versioned"]
        environment:
          KG_MCP_SECRET: ${KG_MCP_SECRET}
        volumes:
          - kg-data:/data
    volumes:
      kg-data:
    
    # librechat.yaml
    mcpSettings:
      allowedDomains:  # a CLOSED list: only the listed domains pass, for every MCP server reached by URL
        - kg-ssot        # the service name in compose
        # - the domains of the other MCP servers this librechat.yaml already uses
    mcpServers:
      kg-ssot:
        type: streamable-http
        url: http://kg-ssot:8080/mcp
        headers:
          X-User-Email: "{{LIBRECHAT_USER_EMAIL}}"
          X-Api-Key: "${KG_MCP_SECRET}"
    

    Uma lista allowedDomains que não está vazia é fechada: só os domínios dela passam, para todos os servidores MCP alcançados por URL. Se o seu librechat.yaml já tem outros servidores, ponha os domínios deles na mesma lista, senão eles param de funcionar.

    O --host 0.0.0.0 é o de dentro do container: sem porta publicada, só os containers da rede do compose alcançam o serviço (a rede padrão do compose não é internal, então o container ainda sai para a internet). Sem KG_MCP_SECRET, o container sai com rc 2 e não serve nada. O segredo fica no .env do compose, nunca no librechat.yaml versionado. Como toda variável de ambiente de container, ele aparece no docker inspect: quem tem acesso ao docker do host o lê, como já lê o .env. - O volume: com um volume nomeado, como no compose acima, o /data já nasce do usuário da imagem. Com uma pasta do host montada em /data, ela tem de ser do uid 10001 (chown 10001:10001 <pasta>); senão o container sai com rc 2 e Permission denied. - A plataforma: a imagem sai só para linux/amd64. - O que a imagem não faz: TLS e exposição para fora da rede do compose ficam com o proxy do adotante, como no perfil. - Num repositório: o armazém de disco e o versionado recusam raiz dentro de um repositório git (veja o kg_store.py). Dentro do repositório, use --backend git --root ., que faz um commit por gravação no branch atual, fora de qualquer PR (rode-o num branch de trabalho), ou rode sem --root para só julgar. - O que fica com o adotante, de propósito: o TLS, o perfil OAuth, cota por usuário e timeout por ferramenta. - Cliente que guarda o tools/list: alguns clientes, como o LibreChat, validam os argumentos contra o tools/list que guardaram ao subir. Depois de uma atualização que muda o inputSchema de uma ferramenta (um argumento novo, um valor novo num enum), reinicie o cliente, ou ele recusa o argumento novo antes de chamar o servidor. Um adotante já tropeçou nisso e passou a reiniciar o cliente no deploy quando o tools/list muda. - As duas eras do protocolo: o servidor fala as duas, e cada requisição escolhe a sua era. - A era nova, 2026-07-28, sem estado: toda requisição traz no params._meta a versão (io.modelcontextprotocol/protocolVersion) e as capacidades do cliente (io.modelcontextprotocol/clientCapabilities). - O que o servidor implementa: server/discover. - O que todo resultado traz: resultType e o serverInfo no _meta. - As dicas de cache: server/discover e tools/list trazem ttlMs e cacheScope, que é private no perfil "atrás de proxy", porque a lista depende da área. - Os erros, com o status HTTP da especificação: - falta um campo obrigatório do _meta: -32602, com 400; - versão que o servidor não suporta: -32022, com 400 e, em data.supported, a mesma lista do server/discover (as duas eras), por qualquer caminho; uma versão da era antiga pedida no _meta também dá -32022, com a mensagem de que ela só vale pelo initialize; - método desconhecido: -32601, com 404; - header que falta ou diverge do corpo (MCP-Protocol-Version, Mcp-Method, Mcp-Name, com o sentinela base64): -32020, com 400. - A era antiga (2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05): segue com initialize, que responde com a versão pedida quando a conhece. O servidor nunca emite Mcp-Session-Id, e GET e DELETE dão 405. - Medido com clientes oficiais: o SDK Python mcp 2.3.0, que é o v2, na era nova, por stdio e por HTTP; e os SDKs TypeScript 1.30.0, o que o LibreChat usa, e 1.32.1 na era antiga. O CI roda os três.

skills/kg-ssot/: a skill de agente (4.30.0)

A release traz uma skill no formato Agent Skills: um SKILL.md que diz a um agente de código como escrever um grafo que passa, como corrigir pelo código, como consultar e quando ler uma das referências (o contrato, as correções, o vocabulário de domínio, a aresta para outro grafo, o critério pré-registrado e o gate no CI). Ela não tem código próprio: aponta para as ferramentas desta página.

  • Instale com npx skills add marciocar/kg-ssot -s kg-ssot, que copia a pasta para o diretório de skills de cada cliente que você escolher (Claude Code, Codex e outros).
  • No Claude Code, ela é também um plugin: /plugin marketplace add marciocar/kg-ssot e depois /plugin install kg-ssot@kg-ssot.
  • O que ela rende: num teste pré-registrado de 24 execuções, os agentes com e sem a skill acertaram 12 de 12; com ela, gastaram 45% dos tokens de saída e 54% das chamadas de ferramenta. Sem ela, leram os schemas dentro do pacote e escreveram por tentativa.

kg_query.py: a consulta e a projeção do corpus

Lê muitos grafos de uma vez e seleciona nós, sem julgá-los (o veredito é do kg_validate.py). Cada nó sai projetado: as arestas de saída e de entrada, com a outra ponta resolvida para o label, local ou externa, e se essa ponta existe no corpus lido. Com --root, o corpus são os .kg.yaml e .kg.json que o git rastreia sob a pasta (fora do git, todos sob ela), e os caminhos saem relativos a ela, porque é a raiz contra a qual a ponta externa <caminho>#<id> se resolve.

python3 -I -B vendor/kg-ssot/tools/kg_query.py --root . --open --sort impact          # the open queue
python3 -I -B vendor/kg-ssot/tools/kg_query.py --root . --around alvo.kg.yaml#EV_A    # the neighborhood of a node
python3 -I -B vendor/kg-ssot/tools/kg_query.py --root . --text "gate base" --type decision --format json

Saída real, na pasta external-corpus da suíte (cortada):

alvo.kg.yaml  EV_A  evidence/confirmed/DEV imp=3  A evidência sintética que sustenta A.
    → SUPPORTS Q_A  A pergunta sintética A.
    → REFUTES valid-corpus-external-refutes-refuted.kg.yaml#EV_A  A evidência sintética que sustenta A.
    → REFUTES warn-corpus-external-refutes-live.kg.yaml#EV_A  A evidência sintética que sustenta A.
    ← REFUTES valid-corpus-external-refutes-outgoing.kg.yaml#EV_A  A evidência sintética que sustenta A.
    ← SUPERSEDES valid-corpus-target-exists.kg.yaml (o grafo inteiro)
Opção O que faz
--root DIR ou arquivos O corpus. Com --root, o vendor e as fixtures ficam de fora por padrão (vendor/*, fixtures/*, */fixtures/*, docs/materials/* e, desde a 4.20.0, toda pasta com o carimbo .kg-ssot-version); --exclude GLOB (repetível) tira mais caminhos, e --no-default-excludes lê tudo. Um arquivo que você nomeia um a um nunca é excluído
--id, --id-prefix, --type, --status, --plane, --layer, --file Filtros, todos combinados em E
--text PALAVRAS Palavras que o label contém, todas, sem maiúscula nem acento (verificacao casa Verificação, desde a 4.23); --in-narrative procura também na narrative. Quando o texto inteiro é um termo do vocabulário (o pref ou um sinônimo de domínio atual, dobrados, ou a notation, exata), casam também o termo e todo nó que tenha qualquer forma dele como palavra inteira, e o expanded diz qual termo; o sinônimo de caso ou aposentado não expande. Nenhum nó dá um hint, que diz quando o zero vem dos outros filtros, para o zero não passar por "não há"
--semantic PERGUNTA Desde a 4.26, com o extra vector: ordena os nós que passam nos outros filtros pela semelhança com a pergunta (--model, --model-dir; ver o kg_vector.py abaixo). Sozinho, a ordem é a do vetor; com --text, a lista do texto (na ordem do corpus) e a do vetor se fundem por RRF (k=60), com o empate pela posição no vetor. Dê o termo curto ao --text e a pergunta inteira ao --semantic: é o que a bancada mediu (14 de 20 acertos, contra 12 do vetor e 9 do texto). Os filtros valem para os dois, as duas listas são completas, e o --limit só pagina a lista fundida; por isso, no híbrido, o total conta todos os candidatos, e o semantic.text_matches diz quantos casam o --text. O JSON ganha semantic (modo, modelo com revisão e sha256, e de onde veio cada vetor: o índice, embutido na hora, a memória do processo, ou sem texto) e um rank por nó; sem --semantic a saída não muda
--open A fila dos abertos (status open)
--group-by CAMPO Desde a 4.41.0: conta os nós por campo em vez de listá-los (kg-ssot.groups/2 desde a 4.42.0). Veja "Quantos, por campo" e, para label, narrative e o grau, "Vencidos, grau, presença e texto" abaixo
--edges, --count-distinct CAMPO, --min-count N Desde a 4.42.0: as arestas como sujeito, a contagem de distintos e o mínimo. Veja "As arestas como sujeito" abaixo
--where CHAVE=VALOR Desde a 4.39.0: só os nós em que owner, um campo x_* ou (desde a 4.42.0) provenance.<chave> (com ponto para chave aninhada) tem o valor; repetível, e todas têm de valer. Veja "A fila de um dono" abaixo; desde a 4.43.0, também o grau e os operadores ^= e *=, em "Vencidos, grau, presença e texto" abaixo
--due [--as-of AAAA-MM-DD], --has CAMPO, --missing CAMPO Desde a 4.43.0: o que venceu, e se um campo está lá. Veja "Vencidos, grau, presença e texto" abaixo
--verified-before AAAA-MM-DD Os nós sem verified_at, ou conferidos antes da data
--around ID A vizinhança: <caminho>#<id>, ou um id único no corpus; --depth, --edge-type (repetível), --direction out\|in\|both
--sort corpus\|impact\|attention A ordem do corpus, o impacto declarado ou a atenção (impacto × confiança × fator do status × (1 + grau), o valor do radar)
--limit N e --cursor C A página (até 1000) e o cursor da próxima, que vale para a mesma consulta sobre o mesmo corpus
--fields GLOB Chaves do nó que saem em extra, como estão no grafo: --fields 'x_*'
--narrative Inclui a narrative de cada nó
--format json O formato estável kg-ssot.query/1; o kg-ssot.query/2 com --due, que acrescenta due_on e days_late (spec/query.schema.json)

O grafo que não parseia sai em unreadable, com o código parse.* dele, e não derruba a consulta. O label da outra ponta de cada aresta sai cortado em 200 caracteres: um nó de label longo citado por milhares de arestas não multiplica a resposta. rc 0 quando consultou, mesmo sem nó; 2 na entrada quebrada; 3 quando o --semantic não roda aqui (falta o extra vector ou o modelo), para quem chama cair no --text e dizer isso, nunca em silêncio. No servidor MCP, a mesma consulta é a ferramenta kg_query, sobre o armazém: a página para no teto de bytes do servidor (com ao menos um nó), e o next_cursor continua de onde ela parou. Cada chamada relê e reparseia o armazém da área, e num armazém de dezenas de MB a primeira chamada leva dezenas de segundos; o cache do julgamento vem na 4.19.

A cadeia de transmissão (--chain, 4.33.0)

--chain <id> segue uma afirmação até a origem: de cada nó para aquele de onde ele veio, pelo fragmento #<id> do provenance.source, ou, num nó sem provenance.source, pelas arestas TRACES_TO que saem dele (outro tipo com --edge-type). Ela responde quem passou a afirmação para quem, onde a corrente é mais fraca e se ela quebra.

kg-query --root . --chain E_RELAY_B
chain: complete
    E_RELAY_B  confidence=0.7  method=measurement  session B checked it against the repo
    E_RELAY_A  confidence=0.7  method=testimony  session A relayed it
    D_ORIGIN  confidence=0.9  method=testimony  the owner decided
  weakest link: 0.7 at E_RELAY_B, E_RELAY_A
  origin: D_ORIGIN, method testimony, confidence 0.9 (a separate axis)
  grade: strict weak, lenient acceptable
  • Dois eixos, que nunca se somam: o elo mais fraco é o menor confidence entre os repasses; a força da origem é a classe do método dela e o confidence dela.
  • O grau: strong (≥ 0,85), acceptable (≥ 0,60), weak (abaixo). O grau leniente é a faixa do elo mais fraco. O estrito leva também a origem, e uma origem que se apoia em testimony (ou que não tem method) conta como weak, porque ninguém a mediu.
  • A corrente quebrada (um passo para um nó fora do corpus) termina ali, marcada found: false, e sai weak nos dois modos. Uma corrente que bifurca sai com um caminho por ramo; um ciclo é cortado e marcado.
  • --format json dá a mesma projeção, como kg-ssot.chain/1.
  • O passo pelo provenance.source: um fragmento vira passo quando o que vem antes do # é vazio, é o caminho do próprio arquivo, é uma URL que termina no meta.id do grafo, é um arquivo do corpus ou tem a extensão de um grafo. Se o nó que ele nomeia não existe, a corrente sai quebrada. Qualquer outro fragmento (a seção de um README, uma URL externa) é a origem, e nunca cai num nó local com o mesmo id.
  • O caminho antes do # é relativo à raiz do repositório, como na referência externa, e a query string sai. Um nó cujo source nomeia a si mesmo é um ciclo.
  • O provenance primeiro (4.34.0): um nó com provenance.source segue só o fragmento dele, e um source que não nomeia nó de grafo (uma pessoa, uma URL, um README) faz do nó a origem, quaisquer que sejam as arestas que saem dele. A aresta diz o que o nó responde ou em que se apoia; o source diz de onde ele veio. Uma decisão selada pelo dono que TRACES_TO a pergunta que responde é a origem, não o primeiro elo da história da pergunta.
  • Uma origem sem provenance.source sai com declared: false, e a saída diz que a corrente pode seguir fora do que o grafo registra. Uma corrente que termina nela sai weak nos dois modos, de qualquer comprimento, e um source só com # não declara nada. As arestas que o provenance de um nó deixa de fora saem nomeadas (edges_not_followed), para que um tipo pedido com --edge-type nunca seja pulado em silêncio.
  • Uma afirmação repassada de outro repositório grava a ponte no provenance.source como a URL daquele grafo, fixada no commit que foi lido (…/blob/<sha>/<caminho>.kg.yaml#<id>), com method testimony. A corrente mostra esse elo fora do corpus, visível e weak, nunca como uma corrente de um nó só em silêncio.
  • --with PREFIXO=PASTA (4.35.0) lê esse outro grafo de uma pasta local, e a corrente segue por ele. O prefixo é a URL antes do commit (https://github.com/acme/core/blob). Quando a pasta é um repositório git que tem o commit da URL, o grafo é lido naquele commit (o que foi citado, não o que está lá hoje); senão, é lido da árvore de trabalho, e o elo sai com pinned: false e um aviso. Sem --with, ou com um prefixo que não casa, o elo fica fora do corpus. Um caminho relativo dentro daquele grafo resolve naquele repositório, nunca no seu. O ref é o primeiro segmento depois do prefixo, então um nome de branch com barra (feature/x) não resolve; pinned: true quer dizer "lido no ref da URL", que só é um commit fixo quando a URL traz um sha. Quando o ref existe e o arquivo não está nele, o elo sai quebrado: a árvore de trabalho nunca é lida no lugar. A pasta é só lida: um ref que começa com - é recusado.
  • Um elo sem confidence numérico, ou com um fora de [0, 1], é o mais fraco, mostrado com valor nulo, e o grau leniente sai weak. Os elos contam por arquivo e id, e weakest_link.at dá o arquivo de cada um.
  • Limites: até 200 passos (além disso, too_long) e 1000 caminhos (além disso, truncated). Uma corrente quebrada, em ciclo ou longa demais sai weak nos dois modos.
  • O grau leniente deixa a origem de fora: uma corrente de repasses fortes a partir de uma origem fraca sai strong no leniente e weak no estrito. Um repasse que se apoia em testimony não é rebaixado pela classe, porque o confidence dele já dá o preço do repasse; a origem é, porque é onde a afirmação nasce.

A corrente é declarada por quem escreve o grafo: a projeção a mede, não prova quem de fato disse o quê.

Desde a 4.43.0, o --chain é repetível, até 50 ids (acima disso, rc 2), e o id repetido conta uma vez. Um id só imprime o kg-ssot.chain/1, e o id que não se resolve dá rc 2. Mais de um imprime o kg-ssot.chains/1 (spec/chains.schema.json), na ordem pedida: cada entrada traz o kg-ssot.chain/1 inteiro em chain e, em origins, cada origem com o status do nó. O id que não existe, ou que existe em mais de um grafo, fica na entrada dele com outcome: not_resolved e o motivo em error, e o lote segue com rc 0. O lote para de abrir cadeias depois de 1000 caminhos, o teto de uma cadeia: as que sobram saem com outcome: not_run, e o truncated fica true. Peça esses ids num lote menor. O --edge-type vale para o lote inteiro. No servidor MCP, o chain recebe uma lista de ids e, ao lado, só edge_types, e a resposta acima do teto de bytes do servidor é recusada, com o pedido de pedir menos ids.

A atenção e as decisões a revisitar (4.36.0)

Uma decisão é um nó como os outros, e o grafo já guarda a história dela: a decisão nova SUPERSEDES a antiga, a refutação guarda o nó refutado, e nada é apagado. Três visões tornam essa história uma pergunta que se faz.

  • --sort attention ordena os nós pelo peso deles no radar: impact × confidence × fator do status × (1 + grau), em que o grau conta as arestas que tocam o nó e o fator do status é 1,0 para open, confirmed e unverifiable, 1,3 para drifted, 0,2 para superseded, 0,1 para done e 0 para refuted. O empate vai para o id em ordem decrescente. É a mesma fórmula e o mesmo desempate do radar do framework onde o formato nasceu, então os dois dão a mesma fila num mesmo grafo que passa no MUST. O grau fica no escopo do arquivo do nó, como no radar: a aresta conta para as pontas locais do arquivo que a declara, o evento de um trigger também conta, e a aresta que chega de outro arquivo não, então a atenção não muda com o --root. Cada nó da página traz a sua attention; a ordem usa o valor sem arredondar.
  • --decision <id> dá a história de uma decisão: o que ela substituiu e o que a substituiu, em toda a linhagem de SUPERSEDES, o que a refuta, o que a apoia e em que ela se apoia (as SUPPORTS que chegam nela, as TRACES_TO e DEPENDS_ON que saem dela, e o passo do provenance.source; a SUPPORTS que sai dela diz o que ela apoia, não em que ela se apoia), com o status de cada um. --format json dá kg-ssot.decision/1 (spec/decision.schema.json).
  • --revisit lista as decisões vivas (nem superseded nem refuted) que pedem revisão, pela atenção, cada uma com os motivos: pin-changed (desde a 4.40.0: um apoio, ou o nó que ele cita pelo kg-node, a um salto, não bate mais com a versão que fixou, pelo provenance.node_sha256 ou pelo provenance.sha256; é a mesma conferência do kg_trace check, e o item traz changed, pin, source, recorded e current), evidence-due (algo em que ela se apoia passou do reverify_every), review-due (a própria decisão passou do reverify_every) e contested (ela recebe REFUTES e segue viva). A decisão com só reverify_on espera o evento e sai à parte; a decisão com cadência e sem verified_at sai em unstamped, e o apoio fora do corpus em unresolved, para nada sumir em silêncio. O apoio refutado ou substituído não é motivo: inferido das arestas, deu 1 aviso útil em 10 no corpus do framework onde o formato nasceu, e por isso a queda aparece no --decision, onde quem lê vê o status de cada apoio. --as-of AAAA-MM-DD muda a data; o padrão é hoje. --format json dá kg-ssot.revisit/2 (spec/revisit.schema.json; /2 desde a 4.40.0, pelo motivo novo). A fixação que não se confere vai para unresolved: pin_malformed (o hash não tem 64 hex minúsculos, ou um node_sha256 não tem o esquema kg-node) ou pin_source_missing (a fonte fixada não existe mais) ou pin_unchecked (a fonte é uma URL sem --with, um ref que anda, fica fora da raiz ou não é caminho). O nó fixado que sumiu do documento também acende pin-changed, com o current null. As fixações só se conferem com --root, e o apoio lido de outro repositório pelo --with ainda não tem a fixação conferida, como no kg_trace check. É a aprovação de um documento controlado: a decisão é o testemunho, e uma evidência que a SUPPORTS fixa a versão aprovada.
kg-query --root . --open --sort attention --limit 10
kg-query --root . --decision D_RETRY_3
kg-query --root . --revisit

As opções rejeitadas não pedem campo novo: escreva cada opção como um nó, e a decisão escolhida refuta ou substitui as outras, com o motivo na narrativa. Um detector automático de contradições foi medido para esta versão e ficou de fora: ele achava afirmações irmãs, não contradições. A tensão é a refutação que alguém registra, e a atenção diz qual olhar primeiro.

Apoios em outro repositório (4.38.0)

O --with PREFIXO=PASTA vale também com --revisit e com --decision, como no --chain desde a 4.35.0: o apoio dado por uma URL no provenance.source (<prefixo>/<commit>/<caminho>#<id>) é lido no commit fixado, de um clone local. O prefixo acaba logo antes do commit: no GitHub ele inclui o /blob (https://github.com/acme/core/blob=../core). Sem o /blob, a referência lida seria blob, que não fixa nada, e o apoio sai unpinned.

  • O --revisit julga só as decisões do corpus local; um grafo lido de outro repositório é apoio, não decisão a revisitar. O apoio que não se resolve traz o motivo: no_with, not_found, not_pinned_url (uma fonte que não é URL com commit, como dono/repo@ref:caminho), unpinned (lido sem fixação real: o commit não está no clone, a pasta não é repositório git, ou a referência é branch ou tag) ou outside_corpus. O apoio sem fixação nunca é julgado pela árvore de trabalho.
  • O upstream_changed lista, à parte e nunca como motivo de revisão, as decisões locais cujo apoio tem outro status, ou sumiu, na ponta do clone: o commit fixado ficou para trás. Inferido das arestas, o apoio caído deu 1 aviso útil em 10 (4.36.0); esta lista diz outra coisa, que a fonte andou desde que foi lida.
  • O --decision mostra cada apoio externo com o status no commit fixado, pinned e, em head_status, na ponta. A ponta é o HEAD local do clone: rode git fetch e o atualize antes, ou a lista fica vazia.
kg-query --root . --revisit --with https://github.com/acme/core/blob=../core

A fila de um dono: --where (4.39.0)

O --where CHAVE=VALOR deixa só os nós em que a chave tem o valor. A chave é owner ou um campo x_*, com ponto para uma chave aninhada (x_raci.responsible); um escalar casa pelo texto, uma lista quando um dos itens casa, e o nó sem a chave não casa. Repita para somar condições, e todas têm de valer. Vale com o --open, com os outros filtros e com o --sort, e com o --revisit, que então julga só as decisões que casam; a saída JSON ecoa as condições em where, e a saída humana diz que a lista está filtrada. Com o --revisit, todas as listas (as decisões, on_event, unstamped, unresolved e upstream_changed) ficam só com as decisões que casam. A ferramenta kg_query do MCP aceita o mesmo where. Uma chave fora de owner e x_*, ou uma condição sem =, é entrada quebrada (rc 2). Não é o --where do kg_validate, que mostra de que nó sai cada código. Um número do grafo casa pelo valor como o Python o escreve (owner: 1e3 casa owner=1000.0), o texto é comparado em NFC, então a mesma palavra acentuada casa de qualquer jeito que foi digitada, e maiúscula e espaço contam. Uma lista de mapas não é percorrida, e um mapa nunca casa.

É a fila com que uma sessão abre quando várias sessões dividem um grafo de coordenação, cada uma com o seu owner:

kg-query --root . --open --where owner=kg-ssot --sort attention
kg-query --root . --revisit --where owner=core

A saída JSON não traz o owner sem pedir: o --fields owner o devolve no extra de cada nó, e é assim que se conta a fila de todos os donos numa consulta só. Uma página traz 50 nós por padrão e até 1000 com --limit; acima disso, siga o next_cursor. Nó sem owner não está na fila de ninguém; conte-o à parte.

kg-query --root . --open --fields owner --format json --limit 1000

Quantos, por campo: --group-by (4.41.0)

O --group-by CAMPO conta os nós que a consulta acha, pelo valor do campo, em vez de listá-los. O campo é file (o caminho do grafo), node_type, status, plane, layer, owner, um campo x_* (com ponto para chave aninhada, como no --where) ou meta.<chave>, um campo do meta do grafo do nó (meta.id, meta.x_study_destination). Repita para agrupar pela combinação, até 5 campos. O campo que falta vale null e forma o próprio grupo; a data sai em ISO, e a lista ou o mapa sai como o texto JSON dele, de modo que cada nó cai num grupo só e as contagens somam o total. Todos os grupos saem, sem página, da contagem maior para a menor. Os filtros valem antes. A saída JSON é o kg-ssot.groups/2 (spec/groups.schema.json; o /1 até a 4.41.0), e o kg-ssot.query/1 não muda. Não combina com --semantic nem com --cursor, nem com --decision, --chain ou --revisit (rc 2). A ferramenta kg_query do MCP aceita os mesmos campos em group_by, onde file é o nome do grafo no armazém; os grupos não têm página, então a resposta maior que o teto de tamanho do servidor é recusada, com o pedido de estreitar os filtros. true e 1 são grupos separados, como no --where. Alguns valores caem num grupo só com o texto que os escreve: 1 e 1.0 são um grupo; uma data e a mesma data escrita como texto são um; uma lista ou um mapa e o texto do JSON dele são um; e .nan ou .inf e o texto nan ou inf são um. Na saída humana, o valor que não é texto sai em JSON (true, 1, null), e o texto que é JSON válido, vazio, com espaço nas pontas, com dois espaços seguidos ou com caractere de controle sai entre aspas, de modo que dois grupos diferentes nunca saem iguais.

kg-query --root . --type decision --open --group-by file                  # decisões abertas por grafo
kg-query --root . --group-by node_type --group-by status                  # a forma do corpus
kg-query --root . --type evidence --status confirmed --group-by meta.id --format json

Contar é pergunta para o leitor do contrato, não para código sobre o YAML: o leitor pula o vendor e as fixtures, põe em unreadable o grafo que não parseia em vez de adivinhá-lo e aplica os mesmos filtros de toda consulta. O grau de entrada de um nó e as outras contagens de arestas estão em "As arestas como sujeito", abaixo.

As arestas como sujeito: --edges (4.42.0)

O --edges faz a consulta contar e listar arestas em vez de nós: as internas (edges, escopo internal) e as externas (external_edges, escopo external), na ordem do corpus. O --where e o --group-by passam a aceitar os campos de aresta: edge_type, scope, file (o grafo que declara a aresta), meta.<chave> desse grafo, e from.<campo> e to.<campo>, em que o campo da ponta é id, file, found (se a ponta existe no corpus), node_type, status, plane, layer, owner, um campo x_* ou provenance.<chave>. A ponta fora do corpus guarda o id e o file e tem os campos de nó nulos. Sem --group-by, a saída é a lista paginada kg-ssot.edges/1 (spec/edges.schema.json); com ele, o kg-ssot.groups/2 com subject: edges. Os filtros de nó (--type, --status, --text, --around, --chain e semelhantes) não combinam com --edges (rc 2): filtre as arestas com o --where. No --where de arestas, a chave que falta casa null, como no grupo null (no --where de nós, a chave que falta nunca casa). A aresta externa sem from tem o grafo inteiro como origem: o from.id é null, e o from.found diz se esse grafo está no corpus. A aresta cujas pontas não são texto fica fora da contagem, como na leitura do grafo pelos outros comandos.

Duas opções de contagem valem com o --group-by, para nós e para arestas:

  • O --count-distinct CAMPO conta, em cada grupo, os valores distintos do campo em vez das linhas; repita para contar combinações distintas (um nó é to.file mais to.id). O valor null não conta, como no count(DISTINCT) do Cypher; a combinação com algum null também não conta, como no COUNT(DISTINCT a, b) do SQL. A contagem pode então ser 0, e o counted diz distinct:<campos>.
  • O --min-count N deixa só os grupos com contagem a partir de N, como o HAVING do SPARQL; as contagens deixam então de somar o total.
kg-query --root . --edges --group-by edge_type --group-by scope                     # a forma das arestas
kg-query --root . --edges --where scope=internal --group-by to.file --group-by to.id \
  --count-distinct from.id --min-count 9                                            # grau de entrada de 9 origens distintas
kg-query --root . --edges --where edge_type=SUPERSEDES --where from.status=confirmed \
  --where to.status=superseded --group-by scope --count-distinct to.file --count-distinct to.id
kg-query --root . --where provenance.locality=web --group-by file                  # 4.42.0: provenance também nos nós

Desde a 4.42.0, o --where e o --group-by de nós também aceitam provenance.<chave>. Perguntas de dois saltos (uma evidência que restringe uma decisão que leva a uma pergunta) ainda não saem de uma consulta só. A de ausência (um nó que não recebe SUPPORTS) sai, desde a 4.43.0, na próxima seção.

Vencidos, grau, presença e texto (4.43.0)

O --due não recebe data, ao contrário do kg_validate --due. Ele lista os nós de qualquer tipo cujo verified_at mais reverify_every venceu até o --as-of (o padrão é hoje), do mais atrasado para o menos e, na mesma data, na ordem do corpus. Cada nó ganha due_on e days_late (0 é o que vence naquele dia). A conta é a do kg_validate --due, e por isso os dois concordam. O que aquele relatório traz de x_term, x_mirror e x_doc_cycle segue lá, e também a lista dos nós que só têm reverify_on. O nó com reverify_every e sem verified_at legível nunca vence, e o hint diz quantos são: isso inclui o verified_at com hora, que não é data no contrato. A cadência fora da forma também nunca vence, nos dois leitores. Em grafo que já reprova no contrato, as duas listas podem diferir: o id repetido sai uma vez aqui, o primeiro nó, e o id numérico não sai aqui, enquanto o kg_validate --due lista cada linha. O --due é um filtro: combina com os outros e com o --group-by, mas não com --sort impact, --sort attention nem --semantic, que pedem outra ordem (rc 2). A saída JSON é o kg-ssot.query/2, o /1 mais esses campos; toda outra consulta segue em /1.

O --where de nós tem estas formas, e todas as condições têm de valer:

Forma Casa
CHAVE=VALOR O texto do valor: x_n=12 casa o número 12, x_b=true o booleano. A lista casa quando um item dela casa. A chave que falta não casa
CHAVE^=VALOR O texto que começa com o valor
CHAVE*=VALOR O texto que contém o valor
degree.in=N, degree.out=N, degree.total=N A contagem das arestas internas do nó, um número inteiro
degree.in.TIPO=N (também out e total) A mesma contagem, de um tipo de aresta

A chave é owner, um campo x_* (com ponto para chave aninhada), provenance.<chave> ou um grau; label^= é recusado (rc 2), porque o --text e o --id-prefix respondem a isso. O --where do --edges aceita ^= e *= nos campos de aresta, como from.id^=D_, e lá o grau não existe. A chave que ainda termina em ^ * $ ! < > ~ | depois de lido o operador é recusada (rc 2), em nós e em arestas: x_n!=12 não é "diferente de", e esse operador não existe.

  • Operadores de texto. Distinguem maiúsculas, com os dois lados em NFC. Só texto casa: null, chave que falta, número, booleano e .nan nunca casam, como no STARTS WITH e no CONTAINS do Cypher. A data é lida como o texto que a escreve, como num .kg.json, e por isso x_when^=2026-11 casa. Numa lista, basta um item de texto, que é a regra do = aqui (no Cypher, a lista dá null). O valor vazio casa qualquer texto e nada mais. A chave termina no primeiro =, e depois dele o valor é literal: x_f^=a=b procura a=b, e owner=^a procura ^a.
  • Grau. Conta as arestas internas do próprio grafo do nó, linha por linha: a aresta repetida conta de novo, o laço conta em in e em out, e por isso duas vezes em total, e a aresta cuja outra ponta falta conta para a ponta que existe. A aresta externa não conta, nem a que vem de outro grafo. Não é o grau da fórmula da atenção, que segue o radar e conta outras coisas. Se um grafo repete um id, as linhas se somam no primeiro nó, o único que a consulta guarda. O valor é um número inteiro com =: degree.in=x, degree.in=01 e degree.in^=1 são recusados (rc 2). O tipo de aresta que nenhuma aresta interna do corpus tem conta 0, e o hint avisa, porque o zero de um erro de grafia não pode passar por "não há". O mesmo vale para o --group-by e o --count-distinct. No --revisit, cuja saída não tem hint, esse tipo é recusado (rc 2). Toda resposta com o grau diz no hint que a aresta vinda de outro grafo não conta. Assim, degree.in.SUPPORTS=0 quer dizer sem aresta interna SUPPORTS no próprio grafo, e nada além disso: o SUPPORTS de outro grafo não conta, e --edges --where edge_type=SUPPORTS --where scope=external lista esses, para completar a resposta.
  • Presença. O --has CAMPO e o --missing CAMPO aceitam qualquer campo do nó, com ponto para chave aninhada, até 10 de cada. O campo que falta e o null são o mesmo: faltam. O texto vazio, o 0, o false, a lista vazia e o mapa vazio têm valor, como no IS NOT NULL do Cypher. O caminho não entra em lista. O mesmo campo nos dois é recusado (rc 2), e também todo caminho que começa com degree, porque o grau é derivado e não é campo do nó. O caminho que nenhum nó do corpus tem com valor ganha um hint, porque o --has trce responderia 0 e o --missing trce, o corpus inteiro.
  • Em que consultas. O --due, o --has e o --missing filtram as consultas de nós: com --edges, --decision, --chain ou --revisit, dão rc 2. O --where com grau ou operador também vale no --revisit.
  • Agrupar por texto. O --group-by aceita também label e narrative, com o texto inteiro, e true e 1 ficam em grupos separados. Com --min-count 2, acham os nós com o mesmo texto. O grupo une texto em NFC e em NFD numa chave NFC, e o nó segue como está. O nó sem o campo cai no grupo null, como em todo --group-by: o --has narrative o deixa de fora.
  • Campos do agrupamento, em nós. file, node_type, status, plane, layer, owner, label, narrative, um campo x_*, provenance.<chave>, meta.<chave>, e degree.in, degree.out e degree.total, com o tipo de aresta opcional.
kg-query --root . --due --type evidence --where owner=ana                      # o que venceu, na mesa de um dono
kg-query --root . --due --as-of 2027-01-01 --group-by file                     # o que vai vencer, por grafo
kg-query --root . --type decision --status done --where degree.in.SUPPORTS=0   # decisões concluídas sem aresta interna SUPPORTS no próprio grafo
kg-query --root . --group-by degree.total                                      # o histograma do grau
kg-query --root . --type evidence --missing trace --has provenance.source      # evidência com fonte e sem trace
kg-query --root . --group-by label --min-count 2                               # nós com o mesmo label
kg-query --root . --edges --where from.id^=D_ --group-by edge_type             # o que sai das decisões, por um prefixo

A ferramenta kg_query do MCP aceita as mesmas formas em where e group_by, mais due, as_of, has e missing, com as mesmas recusas nas mesmas palavras.

kg_diff.py: o diff por id entre revisões

Compara dois grafos, ou todos os grafos que mudaram entre duas revisões do git, alinhando os nós pelo id e as arestas pelas pontas e o tipo. O que só existe de um lado foi acrescentado ou removido, e o que existe nos dois sai com cada campo que mudou, antes e depois. A ordem dos nós no arquivo não conta; uma troca de id é remoção mais acréscimo; uma lista dentro do nó é comparada inteira; a aresta que troca de tipo com as mesmas pontas sai como mudança de edge_type. A revisão é lida pelo git show, sem checkout. Não é patch: o diff informa, não se aplica.

python3 -I -B vendor/kg-ssot/tools/kg_diff.py notice.kg.yaml erratum.kg.yaml
python3 -I -B vendor/kg-ssot/tools/kg_diff.py HEAD~1:docs/x.kg.yaml HEAD:docs/x.kg.yaml
python3 -I -B vendor/kg-ssot/tools/kg_diff.py --from origin/main --ignore verified_at      # everything that changed on the branch

Saída real, entre dois casos da suíte:

valid-node-touched-only-as-target.kg.yaml (modificado)
  ~ meta.id: valid-chain → valid-node-touched-only-as-target
  + C_ALVO  Nó sintético C_ALVO.
  - C_B  Nó sintético C_B.
  + aresta EV_A -SUPPORTS-> C_ALVO
  - aresta C_B -SUPPORTS-> EV_A
· 1 grafo(s): 1 modificado(s), 0 acrescentado(s), 0 removido(s), 0 não lido(s); nós +1 -1 ~0; arestas +1 -1 ~0

O campo que sai de um caminho e entra noutro com o mesmo valor, num nó, numa aresta ou no meta, aparece como renomeação (x_location → x_storage (renomeado)) quando o valor não é trivial e é único dos dois lados. No JSON, as duas entradas ficam, cada uma com o valor do seu caminho, ligadas por renamed_to e renamed_from; o texto humano e o resumo por campo mostram uma linha só. Com --from e sem caminhos, o vendor e as fixtures ficam de fora (também uma pasta fixtures/ na raiz e, desde a 4.20.0, toda pasta com o carimbo .kg-ssot-version em qualquer dos lados), como no kg_query: uma atualização do vendor muda as fixtures da suíte às centenas. --exclude e --no-default-excludes valem do mesmo jeito. O meta sai junto com as outras chaves da raiz, os valores se comparam na forma JSON do contrato, um arquivo renomeado sai como remoção mais acréscimo, e sem --to (ou com --to WORKTREE, desde a 4.20.0) o lado de depois é a árvore de trabalho, e entram também os grafos novos ainda não adicionados ao git. WORKTREE:CAMINHO lê o arquivo da árvore de trabalho, a partir da raiz do repositório como o REV:CAMINHO e nunca fora dele (o ./ e o ../ do git, relativos ao diretório atual, não valem), para revisar uma edição antes do commit (HEAD:docs/x.kg.yaml WORKTREE:docs/x.kg.yaml); uma branch chamada WORKTREE se lê como refs/heads/WORKTREE. O lado que não se lê (não é UTF-8, ou não parseia) sai em unreadable e conta como diferença; a revisão que começa com - é entrada quebrada. Num argumento, o caminho que existe no disco vence a leitura REV:CAMINHO.

--ignore CAMPO (repetível) tira um campo da comparação, com tudo o que fica abaixo dele (provenance tira os três; no meta, meta.date): o verified_at que um gerador recarimba a cada execução esconde o sinal, e o resumo conta os nós por campo mudado para mostrar isso. --format json dá o kg-ssot.diff/1 (spec/diff.schema.json). rc 0 sem diferença, 1 com diferença, 2 na entrada quebrada.

kg_vocab.py: o vocabulário de domínio

Lê os termos do perfil x_term (capítulo 3) de um corpus, com --root ou com os arquivos, e faz quatro coisas: lista, resolve um texto ou um código para o termo, exporta e confere contra eles os códigos escritos na prosa.

python3 -I -B vendor/kg-ssot/tools/kg_vocab.py terms --root . --as-of 2027-05-01      # termos, sinônimos, o que vence
python3 -I -B vendor/kg-ssot/tools/kg_vocab.py lookup "codigo de barras" --root .     # o termo por trás de um texto
python3 -I -B vendor/kg-ssot/tools/kg_vocab.py lookup "cod barras" --case "edital 12" --root .
python3 -I -B vendor/kg-ssot/tools/kg_vocab.py export --to skos --root . > vocab.ttl
python3 -I -B vendor/kg-ssot/tools/kg_vocab.py export --to ossie --root . > vocab.ossie.json
python3 -I -B vendor/kg-ssot/tools/kg_vocab.py lookup rodada:O1 --root .              # um código, qualificado pelo esquema
python3 -I -B vendor/kg-ssot/tools/kg_vocab.py lint --root . --baseline .kg-ssot/vocab-lint.json --external soc2='CC[0-9]+'
  • O lookup compara sem maiúscula, acento nem espaço repetido. Casa o termo preferido ou um sinônimo. O sinônimo com escopo de caso só casa com --case igual ao caso dele. O sinônimo aposentado ainda casa, marcado como aposentado, e o termo substituído vem com os termos que o substituem. rc 1 quando nada casa.
  • O terms mostra quando cada sinônimo vence (verified_at mais reverify_every, na data do --as-of, hoje por padrão). O sinônimo aposentado nunca vence.
  • O export --to skos grava Turtle em SKOS-XL com os nomes da ISO 25964 (iso-thes). O conceito ganha skos:prefLabel, mais skos:altLabel e skos:hiddenLabel para os sinônimos atuais de domínio, para um leitor de SKOS simples enxergá-los. Cada rótulo vira um skosxl:Label com a forma literal e a proveniência (dc:source, kg:locator, kg:sourceVersion, kg:verifiedAt, kg:reverifyEvery). O sinônimo de caso é ligado por kg:caseLabel, com kg:scope e kg:case, e não por skosxl:altLabel, que implicaria um skos:altLabel válido para o conceito inteiro. Um escopo que não se lê conta como um caso que nunca casa. O aposentado sai como isothes:status "retired" e uma skos:historyNote. O sinônimo que repete um literal já dito no conceito vai por kg:repeatedLabel, e o conceito nunca tem o mesmo rótulo duas vezes. O termo substituído ganha dct:isReplacedBy. O IRI do conceito é <base><meta.id>#<id>, o mesmo que o kg_prov_export.py dá ao nó (--base, urn:kg-ssot:graph: por padrão); sem meta.id, o caminho relativo dado, ou o nome do arquivo, nunca um caminho absoluto.
  • O export --to ossie grava um item por termo vivo para o Apache Ossie: name (o termo preferido), description, ai_context.synonyms (os sinônimos atuais de domínio) e uma entrada de custom_extensions do KG_SSOT com a proveniência de cada sinônimo, porque o Ossie não tem campo para ela. Você junta cada item à entidade do seu modelo semântico (dataset, field ou metric) pelo nome.
  • O esquema e o código (4.23, contrato v4.14). O terms dá a cada termo o scheme efetivo (o x_term.scheme declarado, ou o grafo) e o scheme_declared. O lookup casa também uma notation, exata (código é código: o1 não é O1), sozinha ou qualificada como esquema:notation. O export --to skos escreve o skos:inScheme de cada conceito e cada esquema uma vez como skos:ConceptScheme.
  • O lint confere os códigos da prosa contra o vocabulário. Ele lê o label e a narrativa de todo nó, acha os códigos por uma regex (--pattern; a padrão pega de uma a três maiúsculas e de um a três dígitos, como A7, O1 e S14, e r com dígitos, como r16; não pega REGRA 87, SAC-234 nem ids de nó, que têm mecanismo próprio) e aponta:
  • vocab.notation-duplicate: a mesma notation em dois termos vivos do mesmo esquema, no corpus inteiro;
  • vocab.code-unresolved: um código que não é notation de nenhum termo vivo (um esquema:código procura só naquele esquema);
  • vocab.code-ambiguous: um código sem esquema que é notation em dois ou mais esquemas do próprio grafo ou, sem nenhum do próprio grafo, em dois ou mais esquemas do corpus. Escreva esquema:código.

Um código resolve nesta ordem: um termo do próprio grafo, depois um sistema de fora, depois o corpus. O esquema de um esquema:código é um slug colado antes do código, com borda de palavra (my_sa:T7 e urn:kg:E5 não são qualificados). O esquema declarado e o esquema do grafo (o meta.id) dividem um espaço de nomes, aqui, no export SKOS e nos dois leitores. As regras da repetição são as do leitor, e o código do próprio termo resolve no label dele.

O --external soc2='CC[0-9]+' (repetível) declara os códigos de um sistema de fora (SOC 2, CVE, ISO), que resolvem sem termo. A regex que casa o texto vazio é recusada. A catraca é a do gate: --baseline <arquivo> --update grava os achados de hoje, e daí em diante --baseline <arquivo> reprova só pelo achado novo ou acima da contagem da base, para os códigos que já existem não travarem e os novos nascerem com nome; a base gravada com outro --pattern é recusada. A ocorrência que a base já tinha no outro estado do mesmo código (sem termo ↔ ambíguo, mesmo arquivo e código) sai como reclassified e não reprova: nomear o primeiro termo de um código que colide muda o estado das menções antigas, e quem nomeia não paga por elas. rc 1 com achado que reprova ou com grafo que não se lê, 2 com entrada quebrada. - Com --format json, o terms, o lookup e o lint usam o formato kg-ssot.vocab/1 (spec/vocab.schema.json). A ferramenta não julga: o x_term fora do perfil é lido no que dá para ler, e o SHOULD acusa o resto.

kg_criterion.py: o critério pré-registrado (4.21.0)

O leitor já avisa, entre nós do mesmo grafo, o critério que não existe, o sha256 citado que não é o da versão e o resultado medido depois de ver o dado (capítulo 3, x_criterion e x_measured_under). Ele não calcula hash nem lê corpus, para que um leitor JavaScript fique sem criptografia. Esta ferramenta faz o resto, a partir da raiz do repositório:

python3 -I -B vendor/kg-ssot/tools/kg_criterion.py digest --text "Passes with 9 of 10 replicas."
python3 -I -B vendor/kg-ssot/tools/kg_criterion.py digest --ref criteria/a7.tsv                  # o arquivo inteiro
python3 -I -B vendor/kg-ssot/tools/kg_criterion.py digest --ref data/judge-verdicts.json#CU1      # um item de lista
python3 -I -B vendor/kg-ssot/tools/kg_criterion.py check --root .
  • digest imprime o sha256 a escrever no x_criterion. As formas:
  • text: UTF-8, com CRLF e CR sozinho trocados por LF, e sem o espaço (U+0020) no fim de cada linha. O TAB fica.
  • ref sem fragmento: os bytes do arquivo com CRLF trocado por LF, e mais nada. Um TSV guarda as colunas vazias, com TAB no fim da linha.
  • ref com fragmento: o item na forma canônica JCS (RFC 8785), de um arquivo .json, .yaml ou .yml. O fragmento é um JSON Pointer (#/criteria/0) ou o id de um item de lista (#CU1), único no arquivo.
  • check confere cada x_criterion com exatamente um de ref e text contra ele:
  • integrity.criterion-digest quando o sha256 não bate;
  • integrity.criterion-ref-unreadable quando o arquivo, o fragmento ou um valor não se lê: um NaN no JSON, um inteiro além de ±(2^53−1), uma chave repetida, uma chave de mapa YAML que não é texto, ou um caminho que não é arquivo comum ou passa de 64 MiB;
  • integrity.criterion-malformed quando a versão não se confere: o sha256 não é texto (um hash só de dígitos, sem aspas, vira número), ou não há exatamente um de ref e text, em texto. O x_measured_under que não é um mapa com uma lista de citações em mapa, e a citação de outro grafo cujo sha256 não é texto (mesmo quando esse grafo não foi dado), saem do mesmo jeito. Desde a 4.21.1; antes, o check os pulava.
  • Citações de outro grafo. O check segue também cada citação <caminho>#<id> de um x_measured_under, pela mesma regra do leitor: integrity.criterion-unknown, integrity.criterion-sha-mismatch, integrity.measured-post-hoc e integrity.measured-order-unknown.
  • O que ele lê. Só com o --root, o check lê os grafos que o git rastreia sob ele, sem o vendor. Com arquivos, lê só eles, cada um pelo caminho relativo ao --root (a pasta atual, sem ele), que é também de onde parte cada ref, então g/a.kg.yaml#C1 acha o arquivo como quer que ele tenha sido digitado; a citação de um grafo que não foi dado é contada como sem conferir, não como achado, e o --exclude dado vale para os arquivos também.
  • Caminhos. O ref não pode sair da raiz. O último # separa o caminho do fragmento, e o fragmento vazio (a.json#) é o documento inteiro. Um .yaml pede um mapa na raiz. A marca de ordem de bytes no começo do --text-file não faz parte do texto.
  • Saída e rc. --format json dá o kg-ssot.criterion/1 (spec/criterion.schema.json). rc 0 sem achado, 1 com achado ou com grafo que não se lê, e 2 na entrada quebrada.
  • O que ele não confere. O check confere que cada versão bate hoje com o texto dela, não o histórico: editar um critério no lugar e escrever o sha256 novo passa, e só um resultado que cite o sha256 antigo mostra a troca. Revise no kg_diff um x_criterion.sha256 que mudou (ele aparece como campo mudado do nó): uma régua nova é uma emenda, um nó novo com amends.
  • No CI. O leitor não calcula hash, então o critério editado no lugar sem sha256 novo só é pego aqui: se os seus grafos usam x_criterion, ponha python3 -I -B vendor/kg-ssot/tools/kg_criterion.py check --root . ao lado do gate.

kg_audit.py: o pacote de auditoria por pergunta (4.22.0)

O auditor pede o pacote por pergunta: a resposta, as afirmações, a evidência com data, a lacuna e o dono, numa planilha. O grafo já guarda tudo isso; esta ferramenta o dispõe, uma linha por item de questionário, em CSV, XLSX ou JSON. Ela não julga a forma (isso é do kg_validate.py) e não escreve nada no grafo.

python3 -I -B vendor/kg-ssot/tools/kg_audit.py --root . --questionnaire "Q-2026" --as-of 2026-10-23
python3 -I -B vendor/kg-ssot/tools/kg_audit.py --root . --format xlsx --output pacote.xlsx
python3 -I -B vendor/kg-ssot/tools/kg_audit.py --root . --format csv --view evidence > evidencia.csv
  • O item é o par (questionário, número) que o x_questionnaire nomeia (name e questions). Quem entra:
  • a pergunta: o nó question com o perfil;
  • a resposta: o nó (não question) com o perfil do item, e o nó sem perfil ligado à pergunta por DEPENDS_ON, numa direção ou na outra (as duas convivem nos grafos reais; a resposta sai uma vez). O nó com perfil já disse a que item responde, então uma aresta nunca o puxa para outro item;
  • a evidência: o nó com SUPPORTS para uma resposta, ou com SUPPORTS ou TRACES_TO para a pergunta, para um pedido ou para um requisito de evidência do item, local ou de outro grafo (external_edges); uma pergunta, um pedido ou um requisito nunca é evidência;
  • a contradição: o nó com REFUTES para a resposta ou a pergunta. Só a resposta viva traz evidência e contradição: a superseded ou a refuted (uma refutação já reconciliada) não fecha nem abre lacuna do item;
  • o pedido de evidência (role: evidence_request) e o requisito de evidência (role: evidence_requirement, "que evidência este item exige", em coluna própria): nunca são tomados por pergunta nem por resposta. Cada evidência diz de que pedido veio (o pedido para o qual ela aponta por SUPPORTS ou TRACES_TO). O item que só um pedido ou um requisito cita também sai, com as lacunas do que falta.
  • As lacunas são calculadas, nunca escritas à mão:
  • no-answer: sem resposta viva (a resposta refuted ou superseded entra na lista, com o status, mas não conta);
  • contradicted: um REFUTES de um nó que não é refuted nem superseded;
  • no-confirmed-evidence: nenhuma evidência com status: confirmed (a evidência não confirmada entra na lista, mas não fecha lacuna);
  • evidence-unstamped: evidência confirmada sem verified_at válido;
  • evidence-cadence-invalid: evidência confirmada cujo reverify_every não é duração, e que por isso nunca venceria;
  • evidence-due: evidência confirmada cujo verified_at mais o reverify_every cai no --as-of ou antes (hoje, por padrão);
  • verdict-open: sem veredito vivo, ou um que não é meets nem not_applicable.
  • Nada some em silêncio. O nó cujo x_questionnaire não tem name ou uma lista questions não vazia, o nó com role fora do enum e o número de questions que não é texto (questions: [7] sem aspas) não entram: saem em skipped, no próprio pacote (a planilha Pulados do XLSX, o --view skipped no CSV, o JSON), no stderr e na listagem humana, e o --fail-on-gap dá rc 1 com eles.
  • As suas colunas. --column <chave> traz uma chave do nó de resposta (uma x_ sua, como a lista de cards de remediação) como mais uma coluna. Uma lista vira itens separados por ;.
  • Os formatos.
  • csv: as perguntas (uma linha por item) ou, com --view evidence, a evidência (uma linha por item e evidência, com os problemas do carimbo) ou, com --view skipped, o que foi pulado, em UTF-8 com a marca de ordem de bytes para o Excel ler. A célula que começa com =, +, -, @, tab ou retorno de carro ganha um apóstrofo na frente, para nunca rodar como fórmula (a regra da OWASP contra injeção em CSV).
  • xlsx: as três planilhas (perguntas, evidência, pulados) num arquivo, escrito só com a biblioteca padrão, todo valor como texto, o cabeçalho congelado e a célula cortada no teto do Excel, de 32.767 unidades UTF-16. O mesmo pacote dá os mesmos bytes.
  • json: kg-ssot.audit/1 (spec/audit.schema.json), com tudo aninhado.
  • human: uma listagem curta para o terminal.
  • Saída e rc. rc 0 quando exportou, 1 quando um grafo não se lê (o pacote ficaria incompleto em silêncio) ou, com --fail-on-gap, quando um item tem lacuna ou algo foi pulado, e 2 com entrada quebrada. O xlsx pede --output. Dê o corpus com --root: a aresta para outro grafo se resolve pelo caminho a partir da raiz.

kg_trace.py: o trace que não resolve (4.25.0)

O trace: de um nó diz onde mora o "porquê" dele. O leitor confere que ele está lá, nunca que ainda aponta para algo: o leitor não toca o disco, então roda em qualquer lugar, inclusive em JavaScript. Um trace: que aponta para um arquivo movido ou renomeado passa no gate e engana quem volta a ele. Esta ferramenta confere a âncora a partir da raiz do repositório, como o kg_criterion.py confere um sha256.

python3 -I -B vendor/kg-ssot/tools/kg_trace.py check --root .
python3 -I -B vendor/kg-ssot/tools/kg_trace.py check --root . --external-root memory/ --format json
  • O que ele lê. Os .kg.yaml e .kg.json que o git rastreia sob a raiz, fora do vendor, de docs/materials/ e das pastas de fixture (fixture/, fixtures/, __fixtures__/, testdata/, __snapshots__/, em qualquer nível). O --exclude soma padrões, e o --no-default-excludes tira os padrões. Um trace que não é texto conta como not_text; um nó com id repetido ou fora de texto, que o gate já reprova no MUST, não é lido aqui. Se os seus grafos ancoram numa pasta de fora do repositório (a memória de uma sessão), o check fica vermelho até que você a declare com --external-root. Sem nenhum grafo lido, ele avisa.
  • O que é julgado. O texto do trace até o primeiro : ou # (arquivo:linha, arquivo#seção), e só quando parece caminho do repositório. Fora de julgamento, e contados à parte, nunca em silêncio:
  • absolute: um caminho absoluto ou uma URL (outra máquina ou rede);
  • external_root: um prefixo que você declara com --external-root (uma pasta que mora fora do repositório);
  • not_path: um caractere fora de [A-Za-z0-9_./-] (um comando, prosa), um nome sem barra (palavra solta, chave de configuração), ou o primeiro segmento com ponto e sem começar por ponto (um hostname, support.example.com/...).

Um nome sem barra só entra quando é um arquivo rastreado na raiz do repositório com o nome exato (ARCHITECTURE.md), e nenhum arquivo de mesmo nome fica ao lado do grafo nem na pasta pai dele. ./x e x são o mesmo alvo. - Onde resolve. Na raiz do repositório, na pasta do grafo ou na pasta pai do grafo (esta só quando o grafo não está na raiz). Um alvo que sai da raiz (../irmão/x) não resolve: o clone do CI não o tem. - Saída e rc. Cada alvo que não resolve é integrity.trace-target-missing. --format json dá kg-ssot.trace/1 (spec/trace.schema.json). Desde a 4.25.1 ele lista também resolved_targets: para cada trace que resolve, o arquivo e o id do nó, o alvo julgado, o path do que o check viu existir, relativo à raiz e com os links seguidos (para que um link seguido de .. não aponte para outro arquivo), e o kind, file ou dir, que é o que um índice de leitura precisa. Quando o trace passa por um link, o path é o destino real, não o texto (no Windows o sistema resolve o .. no texto antes do link, e link/../x é julgado como x); o trace que cai na própria raiz é o diretório .; o path nunca termina em barra, nem no diretório (o kind diz que é um); e o path guarda a caixa que o sistema de arquivos devolve. O campo é aditivo, e a saída da 4.25.0 segue válida. rc 0 quando todo trace julgado resolve, 1 com um alvo faltando ou um grafo que não se lê, 2 com entrada quebrada, e 3 quando nenhum grafo foi lido (desde a 4.27: o corpus vazio não é "todo trace resolve" para um CI que só olha o rc). Estas regras vêm do resolvedor de um adotante medido em milhares de traces; cada corte foi pago por um falso positivo.

O locator JSON (4.35.0)

Quando um nó aponta para dentro de um arquivo JSON, o provenance.locator dele pode ser um JSON Pointer em forma de fragmento (#/color/brand/primary, RFC 6901), e o kg_trace check o segue dentro do arquivo. Assim, um token ou uma chave que some do JSON é pego, e não só o arquivo.

  • O que é julgado. Um provenance.locator que é # ou começa com #/, contra um arquivo .json do repositório: o caminho do provenance.source quando ele é caminho do repositório, senão o do trace:. Um caminho de fonte que não existe mais já é integrity.trace-locator-missing: o locator nunca é julgado em outro arquivo. Na fonte, um nome solto com extensão (tokens.json) é caminho, porque a fonte não é prosa; uma fonte que sai da raiz do repositório (../x.json) é contada como outside_root, sem julgamento. O fragmento perde o percent-encoding, depois ~1 vira / e ~0 vira ~; objeto se segue por chave e lista por índice decimal sem zero à esquerda.
  • Não julgado, contado: o locator que não é ponteiro (not_pointer), a fonte por URL ou caminho absoluto (absolute) e o ponteiro sem arquivo .json do repositório para ler (no_json_file).
  • Saída e rc. O ponteiro que não resolve é integrity.trace-locator-missing, e o JSON que não se lê é integrity.trace-locator-unreadable; os dois dão rc 1. A saída JSON ganha locators, aditivo e fora do required, então a saída da 4.25 segue válida. Ponha o caminho também no trace:: é ele que diz que o próprio arquivo sumiu.

O locator pelo esquema, e o hash da fonte (4.37.0)

Quando um nó declara provenance.scheme ou provenance.sha256 (contrato v4.17), o kg_trace check abre a fonte e os confere.

  • A fonte. O arquivo do repositório que o provenance.source nomeia ou, com --with PREFIXO=PASTA, o arquivo que uma URL nomeia no commit da URL (<prefixo>/<commit>/<caminho>), lido de um clone local do outro repositório com git show. A pasta tem de ser a raiz de um clone git, e a referência da URL, o nome de um commit: o commit que o clone não tem, ou a pasta que não é a raiz de um clone, é missing_source, e o nome de branch ou de tag (main, HEAD) é unpinned, porque anda. Uma fonte fixada nunca é conferida contra outra versão. A URL sem --with que case é contada como absolute.
  • O hash. O provenance.sha256 contra o conteúdo da fonte como o git o guarda: o arquivo rastreado e sem mudança desde o índice é lido do blob, então um checkout que troca as quebras de linha (core.autocrlf, o padrão do git no Windows) dá o mesmo hash que no Linux, e o mesmo de git show <commit>:<caminho> | sha256sum. O arquivo com mudança, ou fora do git, é lido do disco. A diferença é integrity.trace-sha256-mismatch, a fonte mudou depois de lida.
  • O locator. text (o intervalo de linhas ou de caracteres existe, com os caracteres em code points e cada quebra de linha contada como um, como pede a RFC 5147), csv (a linha, a coluna ou a célula existe, a coluna da célula contra a linha dela), quote (o texto exato aparece, com os espaços em sequência contados como um), kg-node (o nó existe no grafo) e json-pointer (ele resolve numa fonte .json, nas duas formas da RFC 6901). Fora do alcance é integrity.trace-locator-out-of-range; a citação que não está lá, integrity.trace-quote-missing. Não julgado, e contado, nunca como ok: pdf, media e html (form_only, julgados pela forma no schema), o locator fora da gramática do esquema, o scheme fora da tabela ou o sha256 fora da forma (malformed, que o schema já alerta), o JSON Pointer numa fonte que não é JSON (not_json) e a fonte que não é UTF-8 (not_text). O ponteiro julgado aqui não é julgado de novo em locators; o que não pôde ser julgado aqui (a fonte que sumiu, a âncora no trace:) segue julgado lá, como na 4.35.0.
  • Saída e rc. Cada falha dá rc 1. A saída JSON ganha schemes, aditivo e fora do required.
kg-trace check --root . \
  --with https://github.com/acme/core/blob=../core

O hash de um nó citado, e o digest (4.38.0)

Com scheme: kg-node, o provenance.node_sha256 (contrato v4.18) é comparado com o hash canônico do nó citado, lido no commit fixado: a diferença é integrity.trace-node-sha256-mismatch, e a saída JSON conta node_sha256_checked. Uma mudança em outro nó do mesmo arquivo não mexe nele. O kg_trace.py digest ARQUIVO#ID... imprime o hash a registrar; o leitor JavaScript exporta nodeCanonical(doc, id), a mesma string canônica, para o SHA-256 da plataforma. O hash cobre o nó citado inteiro, verified_at incluído, então recarimbar o nó citado derruba as pontes para ele, e é para isso que ele serve. O nó citado que se repete, ou que não tem forma JCS (um número de valor inteiro fora de ±(2^53−1)), não tem hash a conferir e conta como not_canonical. Quando a ponte leva os dois hashes, o sha256 do arquivo é conferido antes e a mudança num vizinho ainda a derruba: numa ponte kg-node, deixe só o node_sha256. O digest lê a árvore de trabalho sob a raiz; para uma fonte fixada, leia antes o arquivo no commit (git show <commit>:<caminho>).

kg-trace digest docs/graph/seals.kg.yaml#D_RETRY_POLICY

Com --format json (4.39.0), o digest devolve kg-ssot.digest/2 (spec/digest.schema.json; /2 desde a 4.40.0): um resultado por referência, com o node_sha256 e o status ok, not_found, file_missing, unreadable ou no_canonical_form, e o mesmo código de saída do texto. O node_sha256 vem com ok e é null nos outros. Desde a 4.40.0, o arquivo que não existe sai file_missing (no texto, ARQUIVO AUSENTE), e o que não é UTF-8 sai unreadable com o code encoding.not-utf8 (ou io.unreadable, quando existe e não se lê): cada um é resultado da própria referência, e o lote segue. Desde a 4.39.1, o grafo que não parseia sai unreadable, com o code do leitor, e não mais not_found: o nó não foi procurado, então não está ausente. Leia o valor pelo campo, em vez de partir a linha de texto. Só a chamada quebrada para o lote antes de qualquer resultado, nos dois formatos, com rc 2: um caminho fora da raiz, ou uma referência sem ARQUIVO#ID.

O checador de projeção (kg_trace.py projection, 4.35.0)

Uma página de contexto em Markdown (negócio, técnico, design) pode declarar que é projeção de grafos: o projection_of do frontmatter lista os grafos, e a página cita ids de nó entre crases. O checador torna essa disciplina verificável.

python3 -I -B vendor/kg-ssot/tools/kg_trace.py projection --root .
python3 -I -B vendor/kg-ssot/tools/kg_trace.py projection --root . --with https://github.com/acme/core/blob=../core
---
projection_of:
  - docs/graph/product.kg.yaml
  - https://github.com/acme/core/blob/<sha>/docs/graph/core.kg.yaml   # outro repositório, fixado no commit
projection_ignore:   # nomes entre crases que não são id de nó
  - BUILD_TOKEN
---
  • O que é lido. Todo .md rastreado cujo frontmatter tem projection_of, ou as páginas que você nomear. A página nomeada sem projection_of conta como no_projection; na varredura, o frontmatter sem ele nem é lido.
  • O que conta como id citado. Texto entre crases na forma de id (D_ALGO, E_X_1027), na prosa ou dentro de um bloco de código. Um id em texto corrido, fora de crases, não é lido; uma página que declara projection_of e não cita id entre crases sai em uncited, com um aviso, porque nada foi conferido. As palavras do próprio contrato (tipos de aresta como TRACES_TO, valores de enum) e os nomes do projection_ignore são contados à parte.
  • Quatro baldes. resolved (num grafo do projection_of); elsewhere (só em outro grafo do repositório: aviso, não reprova); outside (o projection_of nomeia por URL um grafo de outro repositório que o --with não leu: não julgado, contado); missing (integrity.projection-id-missing).
  • --with PREFIXO=PASTA lê esse outro grafo de uma pasta local, no commit da URL quando a pasta é um repositório git que o tem, como no kg_query --chain. Uma URL que o --with cobre e que não se lê (ausente no ref, fora de UTF-8, que não é grafo) é erro de grafo (rc 1), nunca outside. Um grafo lido da árvore de trabalho, porque o ref da URL não está naquela pasta, sai em unpinned, com um aviso.
  • Saída e rc. --format json dá kg-ssot.projection/1 (spec/projection.schema.json). rc 0 sem missing e com todo grafo do projection_of lido; 1 caso contrário; 2 entrada quebrada (frontmatter que não se lê, --root que não é pasta).

As fontes ausentes: --strict-sources (4.44.0)

A fonte com provenance.scheme ou provenance.sha256 que não está lá, no repositório ou no commit de uma URL lida com --with, é contada em skipped.missing_source, e o check segue dando rc 0: isso fica, por compatibilidade. O --strict-sources lista cada uma (FONTE AUSENTE) e dá rc próprio, o 4, depois de qualquer achado de rc 1, e a saída JSON é o kg-ssot.trace/2, com schemes.missing_sources. A fonte sem nenhum dos dois não se julga, com ou sem a opção, e isso inclui a URL com commit e sem hash: para comparar essas, use o kg_refs.py pins.

O provenance.source na forma repo#id@commit não se lê aqui, e quem o compara é o kg_refs.py pins. Quando a forma ocorre, com ou sem a opção, ela é contada numa linha própria da saída humana e em schemes.repo_refs, e a saída JSON é o kg-ssot.trace/2. Um corpus sem essa forma e sem a opção recebe o mesmo kg-ssot.trace/1 de antes.

kg_refs.py: as citações entre repositórios (4.44.0)

Quem cita este nó, antes de você apagá-lo? E o que mudou no que você cita? A citação fixada segue válida no commit dela, e por isso nada avisa o grafo que cita. Esta ferramenta pergunta, sobre uma lista que você declara, e é consulta, não alarme: a mudança nunca altera o código de saída, e nenhuma opção faz isso.

kg-refs cited-by billing:D_LIMIT --repo billing=../billing --repo platform=../platform
kg-refs cited-by billing:D_LIMIT --repo billing=../billing --repo platform=../platform --at billing=abc1234  # um estado anterior
kg-refs pins --repos repos.txt --ignore '*' --format json

A lista é o --repo NOME=PASTA, repetível, ou o --repos ARQUIVO, com um NOME=PASTA por linha (# comenta, e a pasta relativa parte do arquivo). Sem lista, a ferramenta recusa (rc 2): não adivinha repositórios. Um apelido é mais um nome para a mesma pasta: quando os seus grafos escrevem um repositório de dois jeitos, repita o --repo (--repo billing=../billing --repo billing-old=../billing), e os dois nomes dão no mesmo clone, lido uma vez. O --known NOME, repetível, declara a sua rede, com pasta ou sem: o nome que também tem pasta na lista está simplesmente coberto, com o dono escrito ou não. A pasta é lida só pelos objetos do git, no HEAD ou no commit do --at, sem rede e sem a árvore de trabalho, e por isso o que você não commitou não conta. Os grafos seguem as regras de arquivo do kg_query (o vendor e as fixtures ficam de fora; --exclude, --no-default-excludes). Só os campos de nó são lidos.

As formas lidas, em qualquer campo de nó:

Forma Exemplo
URL, inclusive raw https://host/dono/repo/blob/<commit>/caminho.kg.yaml#ID
Dono e repositório dono/repo@<sha>:caminho
Repositório e commit repo@<sha>:caminho#ID, ou repo@<sha>#ID
Repositório, caminho, commit repo:caminho@<sha>#ID
Sem caminho repo#ID@<commit>, com o provenance.node_sha256, se houver
Fonte e locator provenance.source com o commit, e o id no provenance.locator

Sem caminho, o id é procurado nos grafos daquele commit, e dois nós dão ambiguous, nunca um palpite. Num arquivo que não é grafo, o # é uma seção, não um id. O id ou o caminho que só aparece na prosa ao lado da citação, e existe naquele commit, fica marcado inferred; o id que existe também no repositório de quem cita não entra: ali ele nomeia o nó de lá. A citação sem commit (uma branch na URL, repo:caminho) não se compara, e é contada em unpinned.

O cited-by REPO:ID lista cada campo que cita, com a forma e a certainty: explicit (uma citação do nó, ou uma aresta no repositório dele), same_repo ou by_id (uma menção pelo id), local_homonym (uma menção, e o id existe também onde ela está escrita, e pode nomear o nó de lá), e contains_file (uma citação do arquivo inteiro que contém o nó, no estado lido). A citação do mesmo id em outro repositório da lista é contada em elsewhere; a menção ao lado da citação de outra coisa segue sendo menção. Todo commit fixado de uma citação do nó ou do arquivo dele se confere. O target.found_in vazio quer dizer que o nó não está naquele estado: o --at lê um anterior, e quem cita continua aparecendo.

O pins compara cada citação, no commit fixado, com a ponta, e não esconde nada: o summary.groups só diz o que olhar primeiro, com gone e status no alto, e o file aparece como o resto. O --from REPO lista só as citações escritas naquele repositório, e o --status gone,changed lista só esses status. Os dois só recortam a lista, e dizem isso numa linha: a varredura, o summary e o rc seguem os da lista inteira. As arestas do nó se comparam no repositório inteiro, nos dois commits: a aresta que outro grafo acrescenta, como um REFUTES, dá changed edges, e o nó que mudou de lugar dá same, mesmo quando uma aresta passa a escrevê-lo como caminho#ID ou fica no grafo de onde ele saiu.

Status O que quer dizer
same Igual: nada mudou no nó ou no arquivo, mesmo que ele tenha mudado de lugar (moved_to)
changed Mudou, com as classes: status, text (a mudança só de espaço fica aqui, não em stamp_only), edges, other, file (a citação de um arquivo sem nó), stamp_only (só o verified_at e os campos do eixo dele). O nó traz também o current_status, o status dele hoje
gone Sumiu: não está na ponta
ambiguous Mais de um nó tem o id, no commit ou na ponta; candidates os lista
unchecked Não conferido, com o reason: commit_missing, tip_behind_pin (a ponta local é mais velha que o commit fixado, e comparar acusaria na direção errada), no_access (a forma repo#ID@commit de um repositório que conta e não tem pasta aqui), no_target (o repositório inteiro naquele commit, sem id nem caminho ao lado que exista lá), path_absent_at_commit, id_absent_at_commit, hash_mismatch_at_commit, no_graphs (um nó de um repositório sem grafo na ponta, ou um arquivo que sumiu de uma ponta sem grafo com o commit fixado fora do histórico dela, o que não prova nada sobre nenhum dos dois), um grafo que escreve o id e não se lê, no commit ou na ponta (graph_unreadable_at_commit, graph_unreadable_at_tip), ou objetos que faltam ali, como num clone parcial, e por isso ninguém sabe qual grafo escreve o id (objects_missing_at_commit, objects_missing_at_tip). Um grafo que não se lê e não escreve o id não muda nada

O relation diz como o commit fixado se liga à ponta: on_history, ou apart (fora do histórico dela, como depois de um rebase, e mesmo assim comparado). O hash é o node_digest do kg_trace.py digest.

A cobertura é a regra central, porque resposta vazia não prova ausência. Toda saída traz scanned e not_scanned, com o motivo, e o rc repete o código de saída:

rc Quando
0 A varredura da lista declarada foi completa, e todo citado foi conferido
2 Erro de uso, inclusive sem lista
3 Parcial: pasta ausente, que não é a raiz de um clone, ou sem o commit; nenhum grafo lido onde era preciso (no_graphs: as regras de arquivo tiraram todos, com --exclude '*' ou só fixtures/; o --at leu um estado sem nenhum; o repositório do alvo do cited-by, ou um que o pins cita por um nó, não tem nenhum; ou a varredura inteira não tem nenhum; o membro comum da lista, não citado ou citado só por um arquivo, sem grafo nenhum não é parcial, e fica em scanned com graphs: 0); commit ou grafo que não se lê; commit fixado ausente, em qualquer forma (num clone raso, detail: shallow); um repositório do --known, que não tem pasta; ou nome citado com commit que não está na lista
4 Só no pins, numa varredura completa: algo ambiguous ou unchecked

O nome fora da lista só conta com commit. Com caminho ou id, como em billing@<sha>:caminho, é um repositório. O nome@<sha> sozinho precisa de um dono na frente (que não seja um ponto nem um remoto, como em origin/main) ou de um hífen no nome, e por isso 53@<sha> e tools@<sha> são só texto. Daí há três caminhos. Sem opção nenhuma, ele entra em not_scanned, o caminho seguro: você fica sabendo quem não leu. Com o --known, só a lista e os conhecidos contam no rc: o resto vai a mentioned_undeclared, com a contagem, e cada citação com id vai a outside_citations, com quem cita, o campo e o id, sem mudar o rc. O --ignore NOME tira o nome falso, e aceita glob; o --ignore '*' declara a lista completa. A cobertura vence o rc 4. Num corpus com citações do repositório inteiro (repo@<sha> sem alvo), o pins dá rc 4 por elas.

A saída humana põe primeiro, em bloco próprio, o que a lista declarou e falhou, e os nomes de fora numa linha com a contagem; os nomes estão no JSON.

O limite: acha citação errada, não citação que falta. Lê o que os grafos escreveram, nas pastas da lista. A afirmação de ausência que não cita nada não aparece, nem a citação fora de um campo de nó, num comentário ou num arquivo que não é grafo. Não diz nada de um repositório de que você não tem a pasta, e não foi testado por um terceiro sem acesso a elas. Não faz ninguém rodá-lo. O servidor MCP não o tem: ele lê outras pastas.

Mais cinco limites, para você não ler na resposta mais do que ela diz:

  • Dois homônimos na outra ponta da aresta contam como um. Essa ponta se compara só pelo id, e por isso a aresta do nó que passa a apontar, ou deixa de apontar, para um nó de mesmo id em outro arquivo não muda o veredito do nó.
  • Um membro comum na branch errada parece um membro sem grafo. Ele fica em scanned com graphs: 0 e rc 0: olhe o scanned se esperava grafos ali. Só o repositório do alvo e o --at dão rc 3 por isso.
  • A identidade é só pelo nome. Um fork com outro dono e o mesmo nome de repositório dá na pasta daquele nome; a ferramenta compara commits, não confere o dono.
  • Vazio com rc 0 quer dizer entre os listados. É uma resposta completa sobre a lista que você declarou, não sobre o mundo. O rc 0 não diz nada de um repositório fora da lista, ou que você tirou com o --ignore. No cited-by, o repo@<sha> sem caminho e sem id também fica de fora, mesmo com o commit ausente: nada o liga ao nó.
  • Um grafo inteiro reordenado sai changed file, sem mudar o rc. A citação do arquivo compara o objeto, e por isso não sabe que os nós são os mesmos; a citação do nó dá same, porque compara o nó.

kg_vector.py: o modelo e o índice da busca por semelhança (4.26.0)

O kg_query --semantic acha o nó quando a pergunta usa outras palavras que não as do label. É opcional: precisa do extra vector (pip install "kg-ssot[vector]", que traz onnxruntime, tokenizers e numpy; no vendor, instale o tools/requirements-vector.txt com --require-hashes). Sem ele, o kit roda como sempre, e só o --semantic e este script recusam, com rc 3 e rc 2 e o que instalar. Nada aqui entra em veredito: o contrato, o gate e o --text não dependem do vetor.

python3 -I -B vendor/kg-ssot/tools/kg_vector.py fetch                 # o modelo, uma vez (cerca de 250 MB)
python3 -I -B vendor/kg-ssot/tools/kg_vector.py index --root .        # o cache, refeito só onde um nó mudou
python3 -I -B vendor/kg-ssot/tools/kg_query.py --root . --text "busca vetorial" --semantic "vale a pena adotar busca vetorial?"
  • O modelo é um arquivo ONNX com a revisão e o sha256 fixados no script (kg_vector.py models): o padrão é o modelo multilíngue que a bancada mediu (cerca de 50 línguas, 128 tokens). O fetch o baixa do Hugging Face para $KG_SSOT_MODEL_DIR (ou .cache/kg-ssot/models na pasta pessoal) e confere o sha256 antes de pôr no lugar; cada carga confere de novo, e a consulta nunca vai à rede. Sem rede, ponha os arquivos lá.
  • O texto de um nó é o label e a narrative, juntos por um espaço; o vetor é a média dos tokens, normalizada. Ele reproduz o embedder da bancada: nos 403 nós dela, o menor cosseno foi 0,9999999.
  • O índice é um cache em <raiz>/.kg-ssot/vector/<modelo>/index.npz, com um .gitignore que o deixa fora do git: um vetor por nó e o sha256 do texto que o gerou, num arquivo só, trocado de uma vez e conferido na carga (forma, dimensão, valores finitos, chaves únicas); o índice que não passa na conferência é ignorado e refeito. O nó editado depois do índice não fica com o vetor velho: a consulta o embute na hora e o conta em embedded_now, e o próximo index refaz só o que mudou. Sem índice, a consulta embute todo candidato (nos 403 nós da bancada, cerca de 15 s; com o índice, cerca de 2 s). Num corpus de 6.385 nós, o primeiro index levou cerca de 10 minutos em 8 núcleos, o seguinte 7 s, e uma consulta híbrida com o índice 7 s. Dê ao index e à consulta os mesmos --exclude: o nó que o índice deixou de fora é embutido na hora a cada consulta.
  • No servidor MCP, o kg_query aceita semantic quando o servidor tem o extra e o modelo; senão a ferramenta responde com um erro que diz o que falta, nunca só com o texto. O servidor não tem índice em disco: ele embute cada nó uma vez por processo e guarda o vetor na memória pelo sha256 do texto, então a primeira chamada semântica num armazém grande é a lenta.
  • O nó sem texto (sem label e sem narrative) vai para o fim da ordem do vetor: o vetor de um texto vazio não diz nada sobre ele. O pacote MCPB, a imagem GHCR e o pacote npm não levam o vetor: ele vem pelo pacote PyPI com o extra, ou pelo vendor com o tools/requirements-vector.txt.

rc 0 feito; 2 com entrada quebrada, extra ou modelo ausente, arquivo que não confere, ou download que falhou.

kg_prov_export.py: a proveniência em PROV-O

Exporta um .kg.yaml para PROV-O em JSON-LD, para quem já usa ferramentas do ecossistema W3C PROV. Cada nó vira uma prov:Entity. Um nó com provenance ganha prov:wasDerivedFrom apontando para a fonte e uma prov:Derivation cuja atividade carrega o method; o locator e a locality vão como propriedades kg:. Uma fonte que é inteira uma URL http(s) vira a própria IRI; qualquer outra fonte (um caminho, um comando, duas URLs juntas) vira um nó em branco rotulado, um por texto distinto, para que nós com a mesma fonte apontem para a mesma entidade.

python3 -I -B vendor/kg-ssot/tools/kg_prov_export.py \
  vendor/kg-ssot/spec/conformance/fixtures/latest/form/method-vocabulary/valid-method-leitura.kg.yaml

Trecho da saída, o nó com provenance:

   "@id": "urn:kg-ssot:graph:valid-method-leitura#EV_A",
   "@type": "prov:Entity",
   ...
   "prov:wasDerivedFrom": {
    "@id": "_:source0"
   },
   "prov:qualifiedDerivation": {
    "@type": "prov:Derivation",
    "prov:entity": {
     "@id": "_:source0",
     "rdfs:label": "Fonte A"
    },
    "prov:hadActivity": {
     "@type": "prov:Activity",
     "rdfs:label": "leitura: seção 3 do documento A"
    },
    "kg:locator": "caso de conformidade"
   }

O que ele não exporta, de propósito:

  • As arestas, por padrão. SUPPORTS, REFUTES e as outras são relações de evidência, e derivação em PROV é relação de produção. Exportá-las como wasDerivedFrom diria algo que o grafo não diz. Com --edges, elas saem de outro jeito, descrito abaixo.
  • O carimbo como tempo de geração. verified_at sai como kg:verifiedAt (tipado xsd:date quando é uma data), não como prov:generatedAtTime: em PROV, o tempo de geração marca quando a entidade passou a existir, não quando foi conferida.

As arestas, com --edges

--edges exporta todas as arestas, de edges e de external_edges, sem perda e sem derivação falsa:

  • Toda aresta vira um recurso kg:Edge. Ele leva kg:edgeType, kg:from, kg:to, o rótulo como rdfs:label e qualquer outra chave (trigger, x_*) como kg:<chave>, com o nome da chave em percent-encoding, para uma chave x_ com espaço ainda dar uma IRI válida. Um valor que não é texto vai como JSON, tipado kg:json. Assim, qualquer tipo, inclusive os da camada domain, volta inteiro a partir do RDF.
  • A ponta externa (<caminho>#<id>) vai como o literal kg:fromRef ou kg:toRef, porque a IRI de um nó de outro grafo depende do meta.id daquele arquivo. Uma ponta que não é id de nó válido (entrada que o contrato recusa, como um caminho absoluto) também vai como literal, para nunca virar uma IRI quebrada que cada leitor RDF lê de um jeito. Um from ausente (o grafo inteiro) vai como kg:fromGraph true.
  • Entre dois nós locais, três tipos ganham também uma tripla-padrão:
  • SUPPORTS ganha cito:supports, da CiTO;
  • REFUTES ganha cito:refutes;
  • SUPERSEDES ganha dcterms:replaces, dos DCMI Terms.

Os outros tipos não ganham nenhuma, porque a PROV não tem par para nenhum dos três e não se achou termo-padrão para o resto. - Nenhuma aresta gera prov:wasDerivedFrom.

Sem a opção, a saída é byte a byte a de antes. Antes da release, o mapeamento foi medido em 213 grafos e 9.988 arestas, contra critérios registrados antes da medição. Todas as arestas voltaram a partir do RDF lido pelo rdflib, e o custo foi de 1,7 vez o da exportação simples.

O documento inteiro, com --full

--full implica --edges e exporta também todo campo de cada nó e do meta. Assim, o YAML carregado pode ser remontado a partir do RDF:

  • Todo campo de cada nó vai como kg:<chave>, inclusive os que já saem por outro termo (label, status, provenance…). Texto vai como literal simples, e qualquer outro valor (número, booleano, nulo, lista, mapa) vai como JSON, tipado kg:json.
  • Um recurso kg:Graph em <base><meta.id> leva cada chave do meta como kg:<chave>, e cada chave extra do topo (as x_*) como kg:top-<chave>.
  • Um valor que não cabe em JSON faz o exportador sair com rc 2, nomeando o campo.

A ordem dos itens e a forma de superfície (aspas, comentários, estilo) não são guardadas. Antes da release, o --full foi medido em 209 grafos, 7.778 nós e 9.906 arestas, contra critérios registrados antes da medição. O YAML carregado voltou igual a partir do RDF em todos os grafos, e exportar o corpus inteiro do core levou 0,25 s e 19,7 MB num runner de CI.

Para ler a saída em ferramentas RDF, instale o extra rdf: pip install "kg-ssot[rdf]" (rdflib) ou, no vendor, o tools/requirements-rdf.txt com --require-hashes. Ele fica fora do perfil gate, e o exportador em si não precisa dele. O CI lê com o rdflib a saída --edges da suíte inteira e dos grafos do repo, e reconstrói toda aresta só a partir do RDF:

import rdflib, subprocess
out = subprocess.run(["kg-prov-export", "--edges", "grafo.kg.yaml"], capture_output=True, text=True, check=True).stdout
g = rdflib.Graph().parse(data=out, format="json-ld")
print(len(g), "triplas")

O exportador não valida o grafo: rode o kg_validate.py antes. --base IRI troca o prefixo das IRIs dos nós (o default é urn:kg-ssot:graph:, e cada nó fica em <base><meta.id>#<id>); o vocabulário kg: mora em urn:kg-ssot:vocab#, fora desse espaço. rc 2 quando o arquivo é ilegível, não é um documento YAML único ou tem meta ou nodes fora da forma.

kg_migrate.py: o migrador por perfil

Propõe a provenance que o contrato v4 pede aos nós confirmed ou PROD que ainda não a têm, a partir de um perfil que o adotante escreve e versiona: uma lista de regras em ordem (spec/kg-migrate-profile.schema.json), em que a primeira que casa decide. Há um exemplo comentado em spec/migrate-examples/company-brain.profile.yaml, medido sobre um corpus real de 25 grafos e 1.024 nós.

python3 -I -B vendor/kg-ssot/tools/kg_migrate.py plan  --profile kg-migrate.profile.yaml --out proposals.tsv
python3 -I -B vendor/kg-ssot/tools/kg_migrate.py apply --profile kg-migrate.profile.yaml
  • O plan não escreve nada. Imprime as contagens por regra e grava, com --out, a planilha de propostas, uma linha por nó, com o que vai para quem revisa e por quê. Leia a planilha antes de rodar o apply.
  • Cada regra faz uma de três coisas: propor a provenance, rebaixar o nó para unverifiable (a fonte é irrecuperável, que é o padrão do contrato) ou só mandar o nó para revisão. O nó que nenhuma regra casa também vai para revisão e não recebe nada.
  • O when casa pelo que o nó já traz: o trace (vazio, URL, caminho, glob, existência na árvore, expressão regular), o tipo, o status, o plano, o verified_against, o source_kind e as arestas SUPPORTS que chegam ao nó, com not e any_of.
  • Os templates usam os campos do nó: {trace}, {trace_path}, {trace_line}, {verified_against}, {supports} e {source_kind}, e {a|b} pega o primeiro que não for vazio. O note do perfil vai no fim de todo detalhe do method, por exemplo não reverificado na migração de 2026-10-20 (o note do perfil de exemplo).
  • O scopes dá verified_scope só por lista de ids, nunca por padrão de texto.
  • O perfil escolhe dentro do contrato e nunca o relaxa. Toda proposta é conferida contra o schema MUST e o schema SHOULD de forma antes de virar proposta. A que reprovaria vai para revisão com o motivo, e o template que resolve para vazio também. Um aviso de coerência que a proposta acende não a barra, porque o aviso diz a verdade sobre o nó. Por exemplo, um testimony num nó PROD acende integrity.testimony-in-prod: a proposta segue, e o nó vai para quem revisa com o aviso nomeado.
  • O apply grava arquivo por arquivo, por texto, e os comentários ficam. Depois ele relê o arquivo e confere a invariância: cada nó fica igual ao de antes, fora as chaves que a ação autoriza, e o diff só acrescenta linhas, fora a linha do status rebaixado. Um arquivo que ganharia código MUST novo, ou aviso SHOULD que o plan não anunciou, fica como estava. A quebra de linha do arquivo é mantida (CRLF fica CRLF), e o arquivo com quebras misturadas é recusado. Rodar de novo não muda nada.
  • Sem lista de grafos, a ferramenta lê os .kg.yaml do git ls-files. Ficam de fora os padrões do kg_gate.py (*/fixtures/* e docs/materials/*), o vendor/*, toda pasta com o carimbo .kg-ssot-version (desde a 4.20.0) e os globs de --exclude, com a semântica do gate: fnmatch sobre o caminho relativo, em que * atravessa /; o **/ do dialeto do perfil, que casa zero pastas, também vale. --no-default-excludes tira os padrões. Atenção: no trace_path_glob do perfil, * não atravessa /. Um grafo da lista que não parseia vira linha de revisão e não aborta a rodada.
  • O nó cujo verified_scope já é o do perfil sai como same, separado dos nós que mudam, e o apply não o toca.
  • O caminho do trace é léxico e fica dentro da raiz do repositório. Ele é normalizado (./, a/../b), e o que sai da raiz com ../ deixa de ser caminho. Um caminho absoluto ou com ~ conta como inexistente, sem consultar o disco da máquina.

O que fica fora, de propósito, porque é julgamento: reescrever label, mudar plano, re-medir, refutar, reconciliar com SUPERSEDES e promover um nó unverifiable. rc 0 quando o plano sai ou o apply termina; rc 1 quando o apply recusou algum arquivo, que fica como estava e com o motivo no relatório; rc 2 com entrada quebrada (perfil fora do schema, regra ou id repetido, expressão regular inválida, when que casaria todo nó, grafo dado na linha de comando que não parseia). O migrador viaja no vendor e não no pacote do PyPI nem no do npm.

keys: a chave estranha vira x_<chave> (4.19.0)

python3 -I -B vendor/kg-ssot/tools/kg_migrate.py keys            # o plano: o que seria renomeado e o que vai para revisão
python3 -I -B vendor/kg-ssot/tools/kg_migrate.py keys --apply    # grava as renomeações

Uma chave que o contrato não conhece reprova no MUST. O keys a renomeia para x_<chave>, o prefixo de extensão, no topo, no meta, nos nós, nas arestas e nas arestas externas, pelo texto, então os comentários ficam. As chaves conhecidas vêm do schema MUST da release, nunca de uma lista no código. A chave vai para quem revisa quando não é slug, quando x_<chave> já existe no mesmo mapa, ou quando o nome novo é um perfil canônico (severity viraria x_severity, cujo valor o perfil julga). Com --apply, cada arquivo só é gravado se a releitura for o documento de antes com só as chaves renomeadas, se o diff tocar só essas linhas e se o arquivo não ganhar código MUST nem aviso SHOULD. A quebra de linha fica como estava, o arquivo com quebras misturadas é recusado, e rodar de novo não muda nada. Medido numa cópia dos 15 grafos do core que reprovavam no MUST por chave estranha: com o meta.title conhecido desde o v4.11 e 76 chaves renomeadas em 12 arquivos, 14 passam; o que sobra também não tem proveniência. Rodado pelo core no próprio corpus (2026-10-23), com os excludes do gate: o gate foi de 170 para 182 de 184 grafos no MUST, e a dívida herdada de 14 grafos para 2, os dois sem proveniência.

nest: as suas chaves x_ viram um perfil canônico (4.21.0)

python3 -I -B vendor/kg-ssot/tools/kg_migrate.py nest --map mapa-nest.yaml            # o plano
python3 -I -B vendor/kg-ssot/tools/kg_migrate.py nest --map mapa-nest.yaml --apply    # grava os perfis

O keys dá o prefixo x_ à chave estranha; o nest vai um passo além e leva as chaves x_ que um adotante inventou para os perfis canônicos de nó do capítulo 3, x_mirror, x_doc_cycle e x_questionnaire. O mapa (spec/kg-migrate-nest.schema.json) tem uma regra por perfil. A regra vale para o nó que tem a chave when, e cada campo do perfil vem de uma fonte: const (um valor fixo), move (uma chave x_ que sai do nó) ou copy (qualquer chave, que fica, como verified_at). A fonte pode passar por match (uma regex com um grupo, lida como o ECMA-262 a lê: \w e \d em ASCII, \s com os espaços Unicode, . sem quebra de linha, $ só no fim do texto; o que os dois motores leem diferente é entrada quebrada, e o valor com caractere além de U+FFFF vai para quem revisa) e por uma tabela de valores, values para o valor exato e prefixes para o prefixo mais longo que casa. Com raw: true, o valor original de cada chave movida que tinha texto além do código (entrou por prefixo, ou a regex cortou parte dele) fica em <perfil>.x_raw; o valor exato da tabela não fica. A chave movida por match ou prefixes perde o texto que o código deixa de fora, então essa regra pede raw: true (sem ele, o mapa é entrada quebrada):

nest_version: "1"
nest:
  - into: x_mirror
    when: x_board_status
    fields:
      system: {const: board}
      state: {move: x_board_status}
      ref: {copy: verified_against, match: "/t/([a-z0-9]+)$"}
      url: {copy: verified_against}
      synced_at: {copy: verified_at}
  - into: x_doc_cycle
    when: x_issuer
    raw: true
    fields:
      issuer: {move: x_issuer}
      state: {move: x_in_force, values: {vigente: in_force}, prefixes: {"vigente (": in_force}}

Nada é chutado. O nó vai para quem revisa, sem ser tocado, quando um valor não tem código na tabela, quando falta a fonte de um campo obrigatório, quando o nó já tem o perfil, quando o perfil que ele ganharia não passa no schema SHOULD do perfil (um synced_at que não é data, um state fora do enum) quando o nó está em estilo de fluxo ({...}), quando a chave when dele é null, quando um valor é .nan ou .inf, ou quando a edição do texto daquele nó não releria igual (um valor movido com âncora &x ou alias *x). O plano ensaia a edição do arquivo inteiro uma vez, e só a divide quando ela falha, então o nó que ela não sabe editar vai para quem revisa ali, e o --apply grava os outros. O --apply grava cada arquivo sob as mesmas conferências do keys: a releitura tem de ser o documento de antes com só as chaves movidas e os perfis novos, e o arquivo não pode ganhar código MUST nem aviso SHOULD. A quebra de linha fica, os comentários fora das chaves movidas ficam, e rodar de novo não muda nada. Medido numa cópia dos dois grafos de um adotante (108 nós com 168 dessas chaves): 106 perfis montados, 2 para quem revisa (dois x_in_force com texto livre que o mapa não cobria), os dois arquivos gravados com o mesmo veredito de antes.