Skip to content

5. Tools

English | Português (Brasil)

← The contract · Contents · Adoption and CI →

The tools that ship in the release live in tools/. They need only Python 3.12 or later and the pinned, hashed versions in tools/requirements.txt (PyYAML and jsonschema, plus the jsonschema dependencies).

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

Every rc follows the same convention: 0 passed, 1 something failed, 2 the input is broken (an unreadable file, a missing baseline, a tag that does not exist). An rc 2 never counts as green.

The examples below run in a demo repository with the kit in vendor/kg-ssot. In a clone of the product, replace vendor/kg-ssot/tools/ with tools/.

Output language

Since 4.18.0, the tools print their human output in English by default, and in Brazilian Portuguese when you ask for it. Each tool picks the language the gettext way and stops at the first variable that is set:

  1. KG_SSOT_LANG, the override for KG-SSOT alone (pt-BR, pt_BR, or en);
  2. LANGUAGE, a colon-separated list (pt_BR:en), where the first language with a catalog wins;
  3. LC_ALL, then LC_MESSAGES, then LANG (pt_BR.UTF-8).

An empty variable counts as unset. C and POSIX mean English. So does a language with no catalog: the variable that is set still decides, and the tools do not fall through to the next one. A bare pt matches pt_BR. A sentence that has no translation yet prints in English. The catalog is locale/pt_BR/LC_MESSAGES/kg-ssot.po, and it travels in the release and in the vendor (also in --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

Data does not change language. The JSON structure and its keys, the codes (form.required.node.label), the exit codes, the place of an occurrence (nó C_RETRY_SAFE, aresta #3, arquivo), and the TSV sheet of kg_migrate.py stay the same in every language. The message and hint fields of a verdict are text for people: they follow the language on the command line. The MCP server speaks English in every locale, because its descriptions, errors and results are protocol and prompt for the model. As in gettext, LANGUAGE only applies when the effective locale is not C, so LC_ALL=C forces English. To make a CI step independent of the language, decide on the rc, as chapter 6 shows.

kg_validate.py: the reference reader

Validates one or more files against the current contract.

python3 -I -B vendor/kg-ssot/tools/kg_validate.py docs/example.kg.yaml docs/broken.kg.yaml
PASS docs/example.kg.yaml
FAIL 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 files; 1 pass the MUST; 1 with a SHOULD warning (kg-contract-v4.schema.json + kg-contract-v4.should.schema.json)

rc 1. To see which node each code comes from, use --where:

python3 -I -B vendor/kg-ssot/tools/kg_validate.py --where docs/broken.kg.yaml
FAIL 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
           → Add provenance with source, locator, and method; with no recoverable source, change the status to unverifiable.
    MUST   form.unknown-key.node.priority  nó C_RETRY_SAFE
           → Rename the key with the x_ prefix (an extension that readers ignore) or remove it.
    SHOULD form.required.node.verified_against  nó C_RETRY_SAFE
    SHOULD integrity.untraced-decision  nó D_RETRY_POLICY
    SHOULD yaml.unquoted-date  arquivo
· 1 files; 0 pass the MUST; 1 with a SHOULD warning (kg-contract-v4.schema.json + kg-contract-v4.should.schema.json)

Useful options:

Option Effect
--where One line per occurrence, with the node or the edge; on a MUST occurrence, the fix comes right below
--corpus DIR Checks the external end of each external_edges entry against the .kg.yaml and .kg.json files that git tracks under DIR (outside a git repository, all of them)
--schema F Validates against another MUST schema (the SHOULD schema next to it comes along, if there is one)
--format json The structured verdict on stdout (see below), for tools and MCP servers
--limit N With --format json, how many occurrences per file (default 50); the summary counts all of them
- in place of the file Reads the graph from stdin, with no temporary file; --stdin-format json reads a .kg.json graph (default yaml)
--should-schema F With --schema, the SHOULD schema that goes with it (in place of the one next to it)
--due YYYY-MM-DD Instead of the verdict, the due report on that date (since 4.15.0): each node with reverify_every whose verified_at plus the cadence falls on or before the date, each node that has a cadence but no stamp, and each node that has only reverify_on. Since 4.19.0 it also lists the canonical profiles with a cadence: each synonym of an x_term that is not retired, x_mirror (synced_at plus resync_every), and x_doc_cycle (checked_at plus recheck_every), with subject and detail in the JSON. The rc is 0 (or 2 on broken input): being due never fails, and the report does not judge the graph (the gate is still kg_gate.py). A file that does not parse shows up as UNREADABLE; in a scheduled job, check that list, because the rc does not flag it. A malformed or zero cadence stays out of the report (the SHOULD warning flags it), and so does a due date after the year 9999. With --format json, the output uses the kg-ssot.due/1 format (spec/due.schema.json)

The --json OUT, --spike, --git, --rev, and --list options belong to the measurement instrument that gave rise to the contract (the "spike"). They write and replay a frozen legacy format and are not part of the stable interface. For tools, use --format json.

With external_edges and no --corpus, the reader warns that it did not check the external end:

PASS docs/decision.kg.yaml
· 1 files; 1 pass the MUST; 0 with a SHOULD warning (kg-contract-v4.schema.json + kg-contract-v4.should.schema.json)
· 1 file(s) with external_edges: the external end was NOT verified (run with --corpus <repository root>)

The structured verdict (--format json)

If you call the reader from a program (a CI job, an MCP server, a chat), --format json returns the verdict in a versioned format, kg-ssot.verdict/1, with a JSON Schema in spec/verdict.schema.json. Real output for a suite case:

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": "Missing required field \"label\" in the node.",
     "hint": "Add label: the node's short claim, up to 280 characters."
    }
   ],
   "total": 1,
   "truncated": false,
   "external_checked": true
  }
 ]
}

(The two sha256 values and the path are shortened here, and a few objects sit on one line.)

  • What is stable: code is the vocabulary of the suite and the gate, and a tool decides by it. message and hint are text for people: they follow the output language and may change.
  • Where the occurrence is: path points into the document (nodes[7].provenance.method) and line is the line in the file, in .kg.yaml and in .kg.json, so an editor can fix the right spot without rewriting the graph.
  • The cutoff: summary counts every occurrence. occurrences holds up to --limit, and truncated and total tell you whether the list was cut. That way a large graph does not flood a model's context.
  • The contract that judged: contract holds the path, the sha256 of each schema, and the release, for anyone who needs to prove which contract judged.
  • The rc: 0 when every file passes, 1 when any file fails (a file that does not parse fails with parse.*, as in the suite), and 2 when the input is broken (an unreadable file, an invalid option).
  • Performance: with libyaml installed (the common case for PyYAML), parsing uses the C parser with the same YAML 1.2 profile. It falls back to the slower pure parser only for the rare texts where the two can differ (a tab, %YAML, a surrogate or \U escape, a BOM past the start).

kg_conformance.py: the suite

Runs the conformance suite against the reference reader. First the metaschema tests itself, then each manifest is validated, then each case runs.

python3 -I tools/kg_conformance.py

The last lines:

—
latest: 512/512 (gates the rc)
optional: 10/24 (report only)

rc 0. latest/ is the current contract, and a case that fails there fails the run (rc 1). optional/ is behavior that a reader may implement (today, the semantic layer of freshness and domain, which the reference reader does not implement), and it never changes the rc. rc 2 means the suite itself is broken: an invalid manifest, a file outside any manifest, an unknown folder.

kg_gate.py: the per-graph ratchet

The gate is for the CI of a repository that keeps graphs. It measures every .kg.yaml file that git tracks and compares the result with a baseline (the .kg-ssot/gate/ folder). The rule: nothing gets worse. A graph that already failed is recorded as inherited debt; a new graph has to pass.

Without a baseline, the gate refuses to run:

GATE BROKEN  missing baseline (.kg-ssot/gate): write the first one with --update

rc 2. The first baseline:

python3 -I -B vendor/kg-ssot/tools/kg_gate.py --update
  MUST   docs/broken.kg.yaml (inherited): form.required.node.provenance · form.unknown-key.node.priority
  SHOULD form.required.node.verified_against: 1 graph
  SHOULD integrity.untraced-decision: 1 graph
  SHOULD yaml.unquoted-date: 1 graph
baseline written: .kg-ssot/gate (2 graphs, 1 in the inherited debt)

A new graph that fails, after git add:

✗ WORSE  docs/new.kg.yaml fails MUST: form.required.node.provenance, form.unknown-key.node.priority
✗ WORSE  debt.form.required.node.verified_against: 1 → 2
✗ WORSE  debt.integrity.untraced-decision: 1 → 2
✗ WORSE  debt.yaml.unquoted-date: 1 → 2
· 3 graphs; 1 pass MUST; 1 in the inherited debt
  MUST   docs/broken.kg.yaml (inherited): form.required.node.provenance · form.unknown-key.node.priority
  MUST   docs/new.kg.yaml (new): form.required.node.provenance · form.unknown-key.node.priority
  SHOULD form.required.node.verified_against: 2 graphs
  SHOULD integrity.untraced-decision: 2 graphs
  SHOULD yaml.unquoted-date: 2 graphs

rc 1. And when the debt is paid (the inherited graph fixed):

↑ improved docs/broken.kg.yaml: passes MUST (lock in the gain: kg_gate.py --update)
↑ improved debt.form.required.node.verified_against: 1 → 0 (lock in the gain: kg_gate.py --update)
↑ improved debt.integrity.untraced-decision: 1 → 0 (lock in the gain: kg_gate.py --update)
↑ improved debt.yaml.unquoted-date: 1 → 0 (lock in the gain: kg_gate.py --update)
· 2 graphs; 2 pass MUST; 0 in the inherited debt

rc 0, with a request to lock in the gain. Run --update in the same PR so the debt does not come back.

Option Effect
--update Writes the baseline. If something got worse, it refuses unless you pass --accept-regression
--accept-regression "<reason>" Accepts a regression; the reason is recorded in the baseline
--exclude GLOB Removes paths from the corpus (repeatable). */fixtures/*, docs/materials/*, and the KG-SSOT vendor (any folder with the .kg-ssot-version stamp, since 4.19.1) are excluded by default; --no-default-excludes brings them back
--granularity node With --update: counts the SHOULD debt per occurrence, not per graph
--no-default-excludes Turns off the default excludes (and then the vendor fixtures enter the corpus)
--repo DIR · --baseline FILE Another repository, another baseline

kg_vendor.py: bring in and verify the release

An adopter never copies the contract by hand. The vendor tool reads a tag with git archive and writes a stamp with the sha256 of each file.

Run it from the adopter's root, with a clone of the public repository in ../kg-ssot. The outputs below come from real runs of the 4.18.0 tool (both update and check) with tag kg-ssot-v4.17.2. Tools from releases before 4.18.0 print in Portuguese. With a newer release, the tag, the commit, and the file count change:

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 updated: vendor/kg-ssot ← kg-ssot-v4.17.2 (d5a6cab1cb09), 504 files

update also writes vendor/kg-ssot/.gitattributes with * -text. A nested attribute wins over the repository's, so the adopter's line-ending rules (*.json text eol=lf, core.autocrlf) do not rewrite the release bytes in the index. The suite has fixtures with CRLF on purpose, and without this they would reach CI altered.

check recomputes the sha256 values and compares them with the stamp. A file that was edited, deleted, or added (including bytecode in a __pycache__) gives rc 1, and check also verifies the vendor's .gitattributes. With --index, check verifies the blobs in the git index, which is what the commit will carry, instead of the working tree. Run it after git add vendor/kg-ssot: it catches, before the commit, a normalization that the working tree hides.

python3 -I -B vendor/kg-ssot/tools/kg_vendor.py check --dest vendor/kg-ssot
✓ vendor intact: vendor/kg-ssot = kg-ssot-v4.17.2 (d5a6cab1cb09), 504 files
Option Effect
--source REPO The repository (a path or a URL, such as https://github.com/marciocar/kg-ssot) that has the tag. Required when you run it from inside a vendor
--dest DIR Where the vendor lives (default vendor/kg-ssot)
--profile gate Brings only the minimum to run the gate and the check (17 files since 4.26.0, without the suite); --profile full goes back to the whole release
--force Replaces a locally edited vendor
--index (on check) Verifies the git index instead of the working tree
--ref COMMIT (on update) The bench before a tag (4.24): brings a commit that is not a release yet, from a repository where you can reach it. The stamp says unreleased
--allow-unreleased (on check and on kg_gate.py) Accepts a bench vendor. Without it, the check and the gate fail on one (kg_gate.py never writes the baseline from a bench vendor)

Why -B? The kit's scripts do not write bytecode, but a tool of yours that imports a kit module writes the .pyc file inside the vendor, and the next check fails. Run everything with python3 -I -B, or with PYTHONDONTWRITEBYTECODE=1.

kg_store.py: the store that writes only what passes

If you write graphs from a program (a write server, a chat, a pipeline that generates the graph of a case), kg_store.py is a library, with a CLI on top, that writes only after the contract's PASS verdict. There are three stores with the same interface (read, list, write, delete, history). The format comes from the name: .kg.yaml is judged under the YAML profile and .kg.json under the contract's JSON profile (chapter 3):

  • DiskStore: only the head on disk, with no history;
  • GitStore: one commit per write or delete. Git runs isolated at the store root and never climbs up to an outer repository. The author comes from the writer (author="Name <email>"), and the committer is a fixed service identity;
  • VersionedStore: the head on disk plus an event log and the versions in the hidden .kg-store/ directory, with no git.

From the PyPI package, it is from kg_ssot import kg_store. To import it from the vendor, put vendor/kg-ssot/tools on sys.path and run without writing bytecode, as in chapter 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"

The gate judges more than the candidate. The external end of each external_edges entry is checked against the store's corpus (in GitStore, also the tracked graphs in dot folders, since 4.27.0), with the candidate already in place. Every graph that points to the written name is judged again, because replacing or deleting a graph can leave another graph's external edge without an end. The store writes only when all of them PASS.

Since 4.20.0, a store object keeps, in memory, the verdict of each graph it has judged, keyed by everything the verdict depends on: the text and its format, the two schemas, limit, the language of the messages, whether there is a corpus, and, for each external end, whether the cited id exists. A graph that points to the written name and has not changed is not judged again while its ends still resolve, so a long process (the MCP server, an app that keeps the store open) pays the cost once. Writing the same text again does not re-judge it against the contract either; the external gate (--gate-cmd), if there is one, still runs on every write. A new process starts with an empty cache, and the first write pays the full cost.

The corpus is the same one the repository gate would see. In GitStore, that means only what is tracked: a .kg.yaml file on disk with no commit does not count, not in the list, not in the judgment, and not in the lock (for the lock, the name is free, and the write replaces the file). Since 4.27.0 (contract v4.15), the GitStore judgment also reads the tracked graphs in dot folders (.github/), as the gate does, so an external end there resolves and a change there can stop a write that leaves it without an end; the store still never writes there, and list(), read() and the MCP kg_query and kg_terms tools see only the store's own names. DiskStore and VersionedStore judge only the store's own names (a dot folder outside git is not read), and they refuse a root inside a git repository, where the gate would read what is tracked and the store would read the disk. And the head changes only once the record is made: if the commit or the event fails, the head goes back to what it was, and the exception reaches the caller.

To regenerate graphs that cite each other, use write_many (since 4.14.0). One at a time, replacing the first graph would leave the second graph's external edge without an end, and the re-judgment would block it. The whole set, including removals (text None), is judged at once with every change in place, and it goes into a single record (one commit, or one block of events), or nothing goes in:

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"]])

On conflict and missing, the name that blocked the set carries the reason, and the others come back as skipped. On gate and not_judged, the judgment covers the whole set, and every name comes back with that reason. The external gate (--gate-cmd), if there is one, judges each change on its own, as in write. Names in the set that differ only by case are refused, as in the store.

The CLI has one subcommand per operation, with JSON output on 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

From the CLI, a graph that passes and one that fails:

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": {} }, ... } ] }
}

(The verdict is shortened here; it is the whole kg-ssot.verdict/1 document.)

  • reason: null when the write happened. "gate" means the contract failed the graph, and the verdict says where. "conflict" means expected_sha256 does not match the head. "missing" shows up when you remove something that does not exist. "not_judged" shows up when the judgment failed or came out outside spec/verdict.schema.json: it never turns into PASS or "gate", so the caller can tell "failed" from "could not be judged". With an external gate, "gate" with error: "rejected by the external gate" means the contract passed and the outside gate failed the graph; the verdict is the external gate's.
  • Optimistic locking: expected_sha256 is the sha256 of the head the writer read, or NEW (on the CLI, new) to require that the name does not exist yet. Writing the same text as the head returns written with unchanged and no commit.
  • The path: the name is relative, ends in .kg.yaml or .kg.json, and has no .. and no hidden segment. To keep a store portable across systems, it refuses to create a name with a reserved Windows segment (CON, NUL, COM1...) or one that ends in a period (what already exists keeps working), and no operation accepts a name that differs only by case from one already in the store. The root, the folders, and the file cannot be links:
  • on macOS and Linux (POSIX), everything opens through dir_fd with O_NOFOLLOW, and swapping a folder in the middle of an operation redirects nothing;
  • on Windows, which has no dir_fd, each step of the path is checked with lstat (symbolic links, junctions, and mount points are refused; a reparse point that does not redirect the name, such as a OneDrive on-demand file, passes), the opened file has to be the same one lstat saw, and the path is checked again after the read and, on a write, before the final swap. This is weaker: someone who can write inside the root can swap a folder between the check and the use. On a file system with no file id (the FAT of a USB drive), comparing the opened file proves nothing, and only the link refusal remains. In a single-person store, in that person's folder, this changes nothing; for a shared store, prefer POSIX.
  • History: history(name) lists the operations, newest first, with ref, op (write or delete), author, date, and message. read(name, ref=...) reads an earlier version: the commit sha in git, the event seq in the versioned store.
  • Isolated git: GitStore reads neither the system config nor the user config (no hooks, no gpg, no autocrlf, no excludes file), so what it stores is byte for byte the approved text, with the sha256 of the result.
  • The external gate (--gate-cmd): the hook through which an adopter plugs in a second gate, its own, without the product depending on that gate. The command runs after the contract passes, on every write and every delete, and the store writes only when both PASS. A graph that the contract fails never reaches the command. A gate run by kg_mcp.py gets KG_SSOT_LANG=en, because its verdict goes to the model; run by kg_store.py, it inherits your language.
  • Input: the candidate text goes in on stdin as UTF-8 (empty on a delete); read it as bytes and decode it, so you do not depend on the system code page. The name and the operation go in the environment, as KG_GATE_NAME and KG_GATE_OP (write or delete). The command inherits the rest of the process environment, except the kg_mcp secret variable (--secret-env).
  • Output: a kg-ssot.verdict/1 document that is valid under spec/verdict.schema.json, with at least one result, rc 0 when everything passes and rc 1 when something fails, and at most 16 MiB (the cap applies while it runs). The gate's stderr is discarded: to diagnose, run the command by hand with the same stdin and the same environment. An occurrence code has to start with a contract layer (parse., form., integrity., or yaml.); an outside gate uses, for example, integrity.external.<name>.
  • Fail-closed: a command that does not run, that runs past --gate-timeout (default 60 s, at most 86,400), that exits with no JSON, with output that is too large, with JSON outside the schema or with no result, with an rc other than 0 or 1, or with an rc that does not match the verdict gives "not_judged", never PASS, and nothing is written. On a timeout, an interrupt (Ctrl+C), a SIGTERM to the process, or output that is too large, the command and the processes it started are killed. On Windows, killing the process by force ("End task") gives no handler a chance to act, and a gate that is running may stay behind. On macOS and Linux, a process the gate leaves running after it ends cleanly is killed too (its process group); on Windows it is not, so an adopter's gate should leave nothing behind. --gate-cmd "" gives rc 2, so an empty variable never starts a server without its gate; --gate-timeout without --gate-cmd and, in kg_mcp.py, --gate-cmd with --read-only (the gate would never run) also give rc 2.
  • The lock: the gate runs under the write lock. A slow gate holds the other writes to the same store until --gate-timeout; over stdio, it holds the server.
  • The same text: writing the text of the latest version again also goes through the gate. If the gate fails it now, the answer is "gate", even with nothing to change.
  • No shell: the command is a list of arguments, never a shell line. On macOS and Linux, the line follows the shlex rules (a backslash escapes; put the path in quotes). On Windows, double quotes group and a backslash stays as it is, as in the command "C:\Program Files\Python\python.exe" C:\gates\x.py. Quotes count around the whole argument: --opt="a b" becomes two arguments, and single quotes do not group. The graph text arrives only on stdin, so $(...) or ; inside it runs nothing.
  • From the library: open_store(..., gate=CommandGate(argv, timeout)), or any callable (name, text, op) -> (verdict, error). A callable that raises also gives "not_judged".

An example gate, with only the standard library, that fails any text containing the word 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
- What stays with the adopter, on purpose: authentication, identity, a per-user area (one root per user), maximum size, number of graphs, timeout, and the cross-process lock where there is neither flock nor msvcrt. - The CLI rc: 0 written or read, 1 refused (gate, conflict, missing, a name that does not exist), and 2 broken input or not_judged.

kg_mcp.py: the reference MCP server

To give a model the contract and the store without writing a server, kg_mcp.py speaks the Model Context Protocol over stdio (one JSON-RPC message per line) or over HTTP (POST /mcp, JSON response, no stream). It uses only the standard library, the reader, and the store, and it is an adapter: the product does not depend on it, and it does not depend on any particular client.

Tool What it does
kg_validate Judges .kg.yaml text without writing it and returns the kg-ssot.verdict/1 (with the store as the corpus, if there is a store)
kg_write Writes through the store, only on PASS; returns the kg_store result with the verdict
kg_delete Removes through the store, if no graph that points to the name fails without it
kg_get Reads a graph, or an earlier version with ref
kg_list Lists the graphs in the store
kg_history The operations on a graph, newest first
kg_query The kg_query.py query and projection over the store: filters, the open queue, the neighborhood, and fields, paginated by cursor; since 4.43.0, also due, as_of, has, missing and chain
kg_terms Since 4.19.0: resolves a domain term by its preferred name or a synonym (the x_term profile), with the source, version, and verification date of each synonym and the term that replaces a superseded one; without text, lists the terms
kg_schema_help The current contract, for a model: the full MUST and SHOULD schemas, a minimal example that passes, the integrity rules, and the fix for the most common codes

Since 4.17.2, each tool announces the MCP annotations in tools/list: the read tools (kg_validate, kg_get, kg_list, kg_query, kg_terms, kg_history, kg_schema_help) with readOnlyHint and idempotentHint, and kg_write and kg_delete with destructiveHint; all of them with openWorldHint: false, because the store is local. They are hints for the client to ask for or skip confirmation; the store's gate still decides what gets written.

Without --root, only kg_validate and kg_schema_help appear. With --read-only, kg_write and kg_delete drop out of the list.

With --store-optional, a root that does not open (a file in place of the folder, a folder without permission) does not bring the server down. It starts with only kg_validate and kg_schema_help and tells the model why, in the instructions, in the description of those two tools, and in a store field of kg_schema_help. That way the model tells the user why there is no kg_write, instead of assuming a read-only mode. Without the option, a root that does not open is rc 2, with the message on stderr.

A client configuration over stdio, with a versioned store outside the repository:

{
  "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>"]
    }
  }
}
Option Effect
--root DIR The store. Without it, only kg_validate and kg_schema_help
--backend disk\|git\|versioned The kind of store (default disk)
--init Creates the root if it does not exist
--author "Name <email>" Who signs the writes
--read-only Removes kg_write and kg_delete
--http PORT · --host H · --allow-remote HTTP instead of stdio; off the loopback, only with --allow-remote, which requires --secret-env
--secret-env VAR · --secret-header H The shared secret, read from the variable VAR, that each HTTP request carries in header H (default X-Api-Key), compared with hmac
--identity-header H The "behind a proxy" profile: each request's identity comes from header H (an email address), with its own area and author; requires --secret-env
--max-bytes N The maximum size of a graph (default 2,000,000)
--max-graphs N Graphs per store, or per area in the profile; past that, a new name is refused
--max-concurrent N HTTP requests at the same time; past that, 503
--store-optional A root that does not open does not bring the server down: it starts without a store and tells the model why
--gate-cmd CMD · --gate-timeout S An external gate after the contract, on every write and removal, also per area in the profile (see kg_store.py)

A refused write, as the model receives it (the structuredContent, shortened):

{
 "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": "Missing required field \"label\" in the node.",
                      "hint": "Add label: the node's short claim, up to 280 characters." } ] } ] },
 ...
}

The tools/call response carries the same object in structuredContent and, as JSON text, in content. A refused write or a name that does not exist comes with isError: true, so the model can fix it and try again.

  • Who signs: the author of the writes is the server's --author or, in the "behind a proxy" profile, the request's authenticated identity. It is never a tool argument: the model does not choose who signs.
  • Limits: --max-bytes (default 2,000,000) caps the text of a graph. The arguments are checked against the inputSchema that tools/list announces: a wrong type, a missing required field, and an unknown field come back as isError. An internal failure becomes JSON-RPC error -32603, and the process keeps serving.
  • The reader behind it: a document that opens more than 1,000 collections at once (in flow style, in block style, or by indentation), or whose aliases expand to more than twice the size of the text, fails with parse.yaml-error before it is built. Without that cap, a 60 KB message brought the process down. A bracket inside text does not count.
  • The protocol: one message at a time; a JSON-RPC batch is refused (it left MCP in 2025-06-18), and the id has to be a string or an integer. The stdio transport reads and writes UTF-8 as bytes, whatever the locale, and the response goes out as ASCII JSON.
  • HTTP: listens on 127.0.0.1 by default. It refuses an Origin whose host is not loopback (against DNS rebinding), an unknown MCP-Protocol-Version (400), and a body without Content-Length. There is no TLS: the server leaves the loopback only with --allow-remote, which requires --secret-env, to run on a private network behind a proxy with TLS.
  • The "behind a proxy" profile (--identity-header): the mode for a client with many users, such as LibreChat, that sends the logged-in user in a header.
  • Refuses by default: every request carries the identity (an email address) and the shared secret. With no identity, with an empty identity (LibreChat replaces a missing placeholder with empty text) or a malformed one, or with the wrong secret, the response is 401, before any tool runs.
  • Area and author: each identity writes to its own area under --root, in a u-<name>-<hash> directory, and cannot see anyone else's. Its writes are signed in the form local-part <email>. The email address is not case-sensitive: Ana@X.org and ana@x.org are the same identity.
  • Per request: identity, area, and author travel in a per-request context, never in a server attribute, which is shared across threads. Keeping them on the server would let a concurrent request write to another request's area, the class of CVE-2026-25536 in the TypeScript SDK. A deterministic test verifies that no request leaves or changes state on the server, and another test, with two identities in parallel, exercises the isolation end to end. A repeated identity or secret header is refused, so a proxy that appends the header instead of replacing it does not let the client choose the identity.
  • The trust model: whoever holds the secret can claim any identity. The secret authenticates the proxy (LibreChat), and the proxy authenticates the user. That is why the secret never goes to an end client, and the server sits on a network that only the proxy can reach.
  • Limits: --max-graphs applies per area and is counted under the write lock, so parallel writes cannot get past it together. initialize, ping, and notifications neither open nor create an area, and with --read-only an area that does not exist yet is not created. The number of areas has no limit: whoever holds the secret creates one per new identity (the trust model above), and the server keeps the open areas in memory while it runs.
  • The root of the areas: checked at startup. An empty value, a missing root without --init, a file, or a link gives rc 2, unless you pass --store-optional, as with a root outside the profile.
  • The untrusted_data envelope: kg_get, kg_list, kg_history, kg_query, and kg_terms return {"untrusted_data": ..., "notice": ...}, so the model treats text written by others as data, never as instructions.
  • What the profile is not: it is "behind a proxy", on a private network. It is not the OAuth profile of the MCP specification, which waits until there is a measured matrix of clients.
  • The OCI image: ghcr.io/marciocar/kg-ssot:<version> ships kg-mcp ready to run as a service next to a client without Python, such as LibreChat. It is built from the same wheel as PyPI, with the dependencies pinned by hash and the base pinned by digest (packaging/oci/Dockerfile), runs as an unprivileged user (uid 10001), and keeps the store in the /data volume. It carries the io.modelcontextprotocol.server.name label, which the MCP Registry uses to verify ownership. The mirror workflow publishes it behind an approval, with the provenance attestation (gh attestation verify oci://ghcr.io/marciocar/kg-ssot:<version> --repo marciocar/kg-ssot).
  • Over stdio: docker run -i --rm ghcr.io/marciocar/kg-ssot:<version> only judges. With -v kg-data:/data and --root /data/graphs --init, it writes.
  • Next to LibreChat: the "behind a proxy" profile, on the compose network and with no port published on the 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}"
    

    An allowedDomains list that is not empty is closed: only its domains pass, for every MCP server reached by URL. If your librechat.yaml already has other servers, put their domains in the same list, or they stop working.

    The --host 0.0.0.0 is the one inside the container: with no published port, only the containers on the compose network can reach the service (the default compose network is not internal, so the container can still reach the internet). Without KG_MCP_SECRET, the container exits with rc 2 and serves nothing. The secret lives in the compose .env, never in the versioned librechat.yaml. Like any container environment variable, it shows up in docker inspect: anyone with access to the host's docker can read it, just as they can already read the .env. - The volume: with a named volume, as in the compose file above, /data starts out owned by the image's user. With a host folder mounted at /data, the folder has to belong to uid 10001 (chown 10001:10001 <folder>); otherwise the container exits with rc 2 and Permission denied. - The platform: the image ships only for linux/amd64. - What the image does not do: TLS and exposure outside the compose network stay with the adopter's proxy, as in the profile. - In a repository: the disk store and the versioned store refuse a root inside a git repository (see kg_store.py). Inside the repository, use --backend git --root ., which makes one commit per write on the current branch, outside any PR (run it on a work branch), or run without --root to only judge. - What stays with the adopter, on purpose: TLS, the OAuth profile, per-user quotas, and per-tool timeouts. - A client that caches tools/list: some clients, such as LibreChat, validate arguments against the tools/list they cached at startup. After an update that changes a tool's inputSchema (a new argument, a new value in an enum), restart the client, or it refuses the new argument before it calls the server. An adopter has already tripped on this and now restarts the client on deploy whenever tools/list changes. - The two protocol eras: the server speaks both, and each request picks its era. - The new era, 2026-07-28, stateless: every request carries in params._meta the version (io.modelcontextprotocol/protocolVersion) and the client capabilities (io.modelcontextprotocol/clientCapabilities). - What the server implements: server/discover. - What every result carries: resultType and the serverInfo in _meta. - Cache hints: server/discover and tools/list carry ttlMs and cacheScope, which is private in the "behind a proxy" profile, because the list depends on the area. - Errors, with the HTTP status from the specification: - a required _meta field is missing: -32602, with 400; - a version the server does not support: -32022, with 400 and, in data.supported, the same list as server/discover (both eras), by any path; a version from the old era requested in _meta also gives -32022, with a message saying it only works through initialize; - an unknown method: -32601, with 404; - a header that is missing or differs from the body (MCP-Protocol-Version, Mcp-Method, Mcp-Name, with the base64 sentinel): -32020, with 400. - The old era (2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05): keeps using initialize, which answers with the requested version when it knows it. The server never issues Mcp-Session-Id, and GET and DELETE give 405. - Measured with official clients: the Python mcp SDK 2.3.0, which is v2, in the new era, over stdio and over HTTP; and the TypeScript SDKs 1.30.0, the one LibreChat uses, and 1.32.1 in the old era. CI runs all three.

skills/kg-ssot/: the agent skill (4.30.0)

The release carries a skill in the Agent Skills format: a SKILL.md that tells a coding agent how to write a graph that passes, how to fix by code, how to query, and when to read one of its references (the contract, the fixes, the domain vocabulary, the edge to another graph, the pre-registered criterion and the gate in CI). It has no code of its own: it points to the tools on this page.

  • Install it with npx skills add marciocar/kg-ssot -s kg-ssot, which copies the folder into the skills directory of each client you pick (Claude Code, Codex and others).
  • In Claude Code, it is also a plugin: /plugin marketplace add marciocar/kg-ssot, then /plugin install kg-ssot@kg-ssot.
  • What it buys: in a pre-registered test of 24 runs, agents with and without the skill got 12 of 12 right; with it, they used 45% of the output tokens and 54% of the tool calls. Without it, they read the schemas inside the package and wrote by trial and error.

kg_query.py: query and projection over the corpus

Reads many graphs at once and selects nodes, without judging them (the verdict belongs to kg_validate.py). Each node comes out projected: its outgoing and incoming edges, with the other end resolved to its label, local or external, and whether that end exists in the corpus that was read. With --root, the corpus is the .kg.yaml and .kg.json files that git tracks under the folder (outside git, all of them under it), and paths come out relative to it, since it is the root that an external end <path>#<id> resolves against.

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

Real output in the suite's external-corpus folder (trimmed):

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 (the whole graph)
Option What it does
--root DIR or files The corpus. With --root, the vendor and the fixtures stay out by default (vendor/*, fixtures/*, */fixtures/*, docs/materials/*, and, since 4.20.0, any folder with the .kg-ssot-version stamp); --exclude GLOB (repeatable) removes more paths, and --no-default-excludes reads everything. A file you name one by one is never excluded
--id, --id-prefix, --type, --status, --plane, --layer, --file Filters, all combined with AND
--text WORDS Words that the label contains, all of them, without case or accents (verificacao matches Verificação, since 4.23); --in-narrative also searches the narrative. When the whole text is a term of the vocabulary (its pref or a current domain synonym, folded, or its notation, exactly), the term and every node that has any of its forms as a whole word match too, and expanded says which term; a case-scoped or retired synonym does not expand. No match gives a hint, which says when the zero comes from the other filters, so a zero does not pass for "there is none"
--semantic QUESTION Since 4.26, with the vector extra: orders the nodes that pass the other filters by similarity to the question (--model, --model-dir; see kg_vector.py below). Alone, the order is the vector's; with --text, the text's list (in corpus order) and the vector's are fused by RRF (k=60), ties going to the vector position. Give the short term to --text and the whole question to --semantic: that is what the bench measured (14 of 20 hits, against 12 for the vector and 9 for the text). The filters apply to both, both lists are complete, and --limit only pages the fused list, so in hybrid mode total counts every candidate and semantic.text_matches says how many match --text. The JSON gains semantic (mode, model with revision and sha256, and where each vector came from: the index, embedded now, the process memory, or no text) and a rank per node; without --semantic the output does not change
--open The open queue (status open)
--group-by FIELD Since 4.41.0: count the nodes by field instead of listing them (kg-ssot.groups/2 since 4.42.0). See "How many, by field" and, for label, narrative and the degree, "Overdue nodes, degree, presence and text" below
--edges, --count-distinct FIELD, --min-count N Since 4.42.0: edges as the subject, distinct counts and a minimum count. See "Edges as the subject" below
--where KEY=VALUE Since 4.39.0: only the nodes where owner, an x_* field or (since 4.42.0) provenance.<key> (a dot for a nested key) has the value; repeatable, all of them must hold. See "The queue of an owner" below; since 4.43.0, also the degree and the operators ^= and *=, in "Overdue nodes, degree, presence and text" below
--due [--as-of YYYY-MM-DD], --has FIELD, --missing FIELD Since 4.43.0: what is overdue, and whether a field is there. See "Overdue nodes, degree, presence and text" below
--verified-before YYYY-MM-DD Nodes with no verified_at, or verified before the date
--around ID The neighborhood: <path>#<id>, or an id that is unique in the corpus; --depth, --edge-type (repeatable), --direction out\|in\|both
--sort corpus\|impact\|attention Corpus order, declared impact, or attention (impact × confidence × status factor × (1 + degree), the radar's value)
--limit N and --cursor C The page (up to 1000) and the cursor for the next one, valid for the same query over the same corpus
--fields GLOB Node keys that come out in extra, as they are in the graph: --fields 'x_*'
--narrative Includes each node's narrative
--format json The stable kg-ssot.query/1 format; kg-ssot.query/2 with --due, which adds due_on and days_late (spec/query.schema.json)

A graph that does not parse shows up in unreadable, with its parse.* code, and does not bring the query down. The label of the other end of each edge is cut at 200 characters: a node with a long label cited by thousands of edges does not multiply the response. rc 0 when the query ran, even with no node; 2 on broken input; 3 when --semantic cannot run here (the vector extra or the model is missing), so a caller falls back to --text and says so, never in silence. In the MCP server, the same query is the kg_query tool, over the store: the page stops at the server's byte cap (with at least one node), and next_cursor picks up where it stopped. Each call rereads and reparses the area's store, and in a store of tens of MB the first call takes tens of seconds; the judgment cache comes in 4.19.

The transmission chain (--chain, 4.33.0)

--chain <id> follows a claim back to its origin: from each node to the one it came from, along the #<id> fragment of its provenance.source, or, for a node with no provenance.source, along its outgoing TRACES_TO edges (another type with --edge-type). It answers who passed the claim to whom, where the chain is weakest, and whether it breaks.

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
  • Two axes, never added up: the weakest link is the lowest confidence among the relays; the origin's strength is its method class and its own confidence.
  • The grade: strong (≥ 0.85), acceptable (≥ 0.60), weak (below). The lenient grade is the weakest link's band. The strict grade also takes the origin, and an origin that rests on testimony (or has no method) counts as weak, because nobody measured it.
  • A broken chain (a step to a node outside the corpus) ends there, marked found: false, and is weak in both modes. A chain that forks comes out as one path per branch; a cycle is cut and marked.
  • --format json gives the same projection as kg-ssot.chain/1.
  • A step by provenance.source: a fragment becomes a step when the part before # is empty, is the file's own path, is a URL that ends in the graph's meta.id, is a file of the corpus, or has a graph extension. If the node it names does not exist, the chain is broken. Any other fragment (a README section, an outside URL) is the origin, and it never falls on a local node with the same id.
  • The path before # is relative to the repository root, as in an external reference, and a query string is dropped. A node whose source names itself is a cycle.
  • Provenance first (4.34.0): a node with a provenance.source follows only its fragment, and a source that names no graph node (a person, a URL, a README) makes the node the origin, whatever edges leave it. An edge says what a node answers or rests on; the source says where it came from. A decision sealed by its owner that TRACES_TO the question it answers is the origin, not the first link of the question's history.
  • An origin with no provenance.source comes out with declared: false, and the output says that the chain may go on outside what the graph records. A chain that ends there is weak in both modes, however long, and a source of only # declares nothing. The edges that a node's provenance leaves out are named in the output (edges_not_followed), so a type asked with --edge-type is never skipped in silence.
  • A claim relayed from another repository writes the bridge in its provenance.source as the URL of that graph pinned to the commit that was read (…/blob/<sha>/<path>.kg.yaml#<id>), with a testimony method. The chain shows that link outside the corpus, so it is visible and weak, never a silent one-node chain.
  • --with PREFIX=FOLDER (4.35.0) reads that other graph from a local folder, so the chain goes on through it. The prefix is the URL before the commit (https://github.com/acme/core/blob). When the folder is a git repository that has the commit in the URL, the graph is read at that commit (what was cited, not what is there today); otherwise it is read from the working tree, and the link comes out with pinned: false and a warning. Without --with, or with a prefix that does not match, the link stays outside the corpus. A relative path inside that graph resolves in that repository, never in yours. The ref is the first segment after the prefix, so a branch name with a slash (feature/x) does not resolve; pinned: true means "read at the ref in the URL", which is a fixed commit only when the URL gives a sha. When the ref exists and the file is not in it, the link is broken: the working tree is never read in its place. The folder is only read: a ref that starts with - is refused.
  • A link with no numeric confidence, or one outside [0, 1], is the weakest one, shown with a null value, and the lenient grade is weak. Links count by file and id, and weakest_link.at gives the file of each.
  • Limits: up to 200 steps (beyond that, too_long) and 1000 paths (beyond that, truncated). A chain that is broken, a cycle or too long is weak in both modes.
  • The lenient grade leaves the origin out: a chain of strong relays from a weak origin is strong lenient and weak strict. A relay that rests on testimony is not marked down by its class, because its own confidence already prices the relay; the origin is, because it is where the claim is made.

The chain is declared by whoever writes the graph: the projection measures it, it does not prove who really said what.

Since 4.43.0, --chain is repeatable, up to 50 ids (more is rc 2), and a repeated id counts once. One id prints kg-ssot.chain/1, and an id that does not resolve is rc 2. More than one prints kg-ssot.chains/1 (spec/chains.schema.json), in the order asked: each entry has the whole kg-ssot.chain/1 in chain and, in origins, each origin with the status of its node. An id that does not exist, or that exists in more than one graph, stays in its own entry with outcome: not_resolved and the reason in error, and the batch is still rc 0. The batch stops opening chains after 1000 paths, the limit of one chain: the rest come out with outcome: not_run, and truncated is true. Ask those ids in a smaller batch. --edge-type applies to the whole batch. In the MCP server, chain takes a list of ids and only edge_types next to it, and an answer above the server's byte cap is refused with a request to ask fewer ids.

Attention, and the decisions to revisit (4.36.0)

A decision is a node like any other, and the graph already keeps its history: a new decision SUPERSEDES the old one, a refutation keeps the refuted node, and nothing is deleted. Three views make that history something you can ask.

  • --sort attention orders the nodes by their weight on the radar: impact × confidence × status factor × (1 + degree), where degree counts the edges that touch the node and the status factor is 1.0 for open, confirmed and unverifiable, 1.3 for drifted, 0.2 for superseded, 0.1 for done and 0 for refuted. A tie goes to the id in descending order. It is the same formula and the same tie-break as the radar of the framework where the format was born, so both give the same queue on the same graph that passes the MUST. The degree is scoped to the node's file, as in the radar: an edge counts for the local ends of the file that declares it, the event of a trigger counts too, and an edge that arrives from another file does not, so the attention does not change with --root. Each node of the page carries its attention; the order uses the unrounded value.
  • --decision <id> gives the history of one decision: what it replaced and what replaced it, along the whole SUPERSEDES line, what refutes it, what supports it, and what it rests on (the SUPPORTS that reach it, its outgoing TRACES_TO and DEPENDS_ON, and the step of its provenance.source; an outgoing SUPPORTS says what the decision supports, not what it rests on), each with its status. --format json gives kg-ssot.decision/1 (spec/decision.schema.json).
  • --revisit lists the live decisions (not superseded nor refuted) that ask for a review, by attention, each with its reasons: pin-changed (since 4.40.0: a support, or the node it cites by kg-node, one step away, no longer matches the version it was pinned to, by provenance.node_sha256 or by provenance.sha256; the same check as kg_trace check, and the item carries changed, pin, source, recorded and current), evidence-due (something it rests on is past its reverify_every), review-due (the decision itself is past its reverify_every) and contested (it receives a REFUTES and is still live). A decision with only reverify_on waits for its event and is listed apart; a decision with a cadence but no verified_at is listed in unstamped, and a support outside the corpus in unresolved, so nothing is dropped in silence. A support that was refuted or superseded is not a reason: inferred from the edges, it gave 1 useful warning in 10 on the corpus of the framework where the format was born, so the fall shows in --decision, where the reader sees the status of each support. --as-of YYYY-MM-DD sets the date; the default is today. --format json gives kg-ssot.revisit/2 (spec/revisit.schema.json; /2 since 4.40.0, for the new reason). A pin that cannot be checked goes to unresolved: pin_malformed (the hash is not 64 lowercase hex, or a node_sha256 lacks the kg-node scheme), pin_source_missing (the pinned source no longer exists) or pin_unchecked (the source is a URL without --with, a moving ref, outside the root, or not a path). A pinned node that disappeared from its document also fires pin-changed, with current null. The pins are checked only with --root, and a support read from another repository through --with does not have its pin checked yet, as in kg_trace check. This is the approval of a controlled document: the decision is the testimony, and an evidence that SUPPORTS it pins the approved version.
kg-query --root . --open --sort attention --limit 10
kg-query --root . --decision D_RETRY_3
kg-query --root . --revisit

Rejected options need no new field: write each option as a node, and let the chosen decision refute or supersede the others, with the reason in its narrative. An automatic detector of contradictions was measured for this release and left out: it found sibling claims, not contradictions. A tension is a refutation someone records, and attention says which one to look at first.

Supports in another repository (4.38.0)

--with PREFIX=FOLDER works with --revisit and --decision too, as with --chain since 4.35.0: a support given by a provenance.source URL (<prefix>/<commit>/<path>#<id>) is read at the pinned commit, from a local clone. The prefix ends right before the commit: for GitHub it includes /blob (https://github.com/acme/core/blob=../core). Without /blob, the reference read would be blob, which pins nothing, and the support comes out unpinned.

  • --revisit judges only the decisions of the local corpus; a graph read from another repository is a support, not a decision to revisit. A support that does not resolve carries its reason: no_with, not_found, not_pinned_url (a source that is not a URL with a commit, such as owner/repo@ref:path), unpinned (read without a real pin: the commit is not in the clone, the folder is not a git repository, or the reference is a branch or tag) or outside_corpus. An unpinned support is never judged against the working tree.
  • upstream_changed lists, apart and never as a reason to revisit, the local decisions whose support has another status, or is gone, at the head of the clone: the pinned commit fell behind. Inferred from the edges, a fallen support gave 1 useful warning in 10 (4.36.0); this list says something else, that the source moved since it was read.
  • --decision shows each external support with its status at the pinned commit, pinned, and, as head_status, at the head. The head is the local HEAD of the clone: run git fetch and update it before, or the list stays empty.
kg-query --root . --revisit --with https://github.com/acme/core/blob=../core

The queue of an owner: --where (4.39.0)

--where KEY=VALUE keeps only the nodes where the key has the value. The key is owner or an x_* field, with a dot for a nested key (x_raci.responsible); a scalar matches by its text, a list when one of its items does, and a node without the key does not match. Repeat it to add conditions, all of which must hold. It works with --open, the other filters and --sort, and with --revisit, which then judges only the decisions that match; the JSON output echoes the conditions in where, and the human output says the list is filtered. With --revisit, every list (the decisions, on_event, unstamped, unresolved and upstream_changed) keeps only the matching decisions. The MCP kg_query tool takes the same where. A key outside owner and x_*, or a condition without =, is a broken input (rc 2). It is not the --where of kg_validate, which shows the node each code comes from. A number in the graph matches by its value as Python writes it (owner: 1e3 matches owner=1000.0), text is compared in NFC, so the same accented word matches however it was typed, and case and spaces count. A list of maps is not searched, and a map never matches.

It is the queue a session opens with when several sessions share one coordination graph, each with its owner:

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

The JSON output does not carry owner unless asked: --fields owner returns it in each node's extra, which is how you count every owner's queue from one query. A page holds 50 nodes by default and up to 1000 with --limit; past that, follow next_cursor. A node without owner is in nobody's queue; count it apart.

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

How many, by field: --group-by (4.41.0)

--group-by FIELD counts the nodes the query finds, by the value of the field, instead of listing them. The field is file (the graph path), node_type, status, plane, layer, owner, an x_* field (a dot for a nested key, as in --where) or meta.<key>, a field of the meta of the node's graph (meta.id, meta.x_study_destination). Repeat it to group by the combination, up to 5 fields. A missing field is null and forms its own group; a date comes out in ISO, and a list or a map comes out as its JSON text, so each node falls in exactly one group and the counts add up to total. Every group comes out, without pages, from the largest count to the smallest. All the filters apply first. The JSON output is kg-ssot.groups/2 (spec/groups.schema.json; /1 until 4.41.0), and kg-ssot.query/1 does not change. It does not combine with --semantic or --cursor, nor with --decision, --chain or --revisit (rc 2). The MCP kg_query tool takes the same fields in group_by, where file is the graph name in the store; groups have no pages, so an answer larger than the server's size limit is refused with a request to narrow the filters. true and 1 are separate groups, as in --where. A few values do fall in one group with the text that writes them: 1 and 1.0 are one group; a date and the same date written as text are one; a list or a map and the text of its JSON are one; and .nan or .inf and the text nan or inf are one. In the human output, a value that is not text comes out as JSON (true, 1, null), and a text that is valid JSON, empty, padded with spaces, holding two spaces in a row or a control character comes out in quotes, so two different groups never print the same.

kg-query --root . --type decision --open --group-by file                  # open decisions per graph
kg-query --root . --group-by node_type --group-by status                  # the shape of the corpus
kg-query --root . --type evidence --status confirmed --group-by meta.id --format json

A count is a question for the reader of the contract, not for code over the YAML: the reader skips the vendor and the fixtures, reports a graph that does not parse in unreadable instead of guessing it, and applies the same filters as every other query. The in-degree of a node and the other edge counts are in "Edges as the subject" below.

Edges as the subject: --edges (4.42.0)

--edges makes the query count and list edges instead of nodes: the internal ones (edges, scope internal) and the external ones (external_edges, scope external), in corpus order. --where and --group-by then take the edge fields: edge_type, scope, file (the graph that declares the edge), meta.<key> of that graph, and from.<field> and to.<field>, where the end field is id, file, found (whether the end exists in the corpus), node_type, status, plane, layer, owner, an x_* field or provenance.<key>. An end outside the corpus keeps its id and file and has the node fields null. Without --group-by, the output is the paged list kg-ssot.edges/1 (spec/edges.schema.json); with it, kg-ssot.groups/2 with subject: edges. Node filters (--type, --status, --text, --around, --chain and the like) do not combine with --edges (rc 2): filter the edges with --where. In the edge --where, a missing key matches null, as it groups under null (in the node --where a missing key never matches). An external edge without from has the whole graph as its source: from.id is null and from.found says whether that graph is in the corpus. An edge whose ends are not text is left out of the count, as in the graph reading of the other commands.

Two counting options work with --group-by, for nodes and for edges:

  • --count-distinct FIELD counts, in each group, the distinct values of the field instead of the rows; repeat it to count distinct combinations (a node is to.file plus to.id). A null value does not count, as in Cypher's count(DISTINCT); a combination with any null does not count either, as in SQL's COUNT(DISTINCT a, b). The count can then be 0, and counted says distinct:<fields>.
  • --min-count N keeps only the groups with a count from N, like SPARQL's HAVING; the counts then no longer add up to total.
kg-query --root . --edges --group-by edge_type --group-by scope                     # the shape of the edges
kg-query --root . --edges --where scope=internal --group-by to.file --group-by to.id \
  --count-distinct from.id --min-count 9                                            # in-degree from 9 distinct sources
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 in nodes too

Since 4.42.0 the node --where and --group-by also take provenance.<key>. Two-hop questions (an evidence that constrains a decision that traces to a question) are not covered by a single query yet. Absence (a node that receives no SUPPORTS) is, since 4.43.0, in the next section.

Overdue nodes, degree, presence and text (4.43.0)

--due takes no date, unlike kg_validate --due. It lists the nodes of any type whose verified_at plus reverify_every is due by --as-of (default: today), the most overdue first and, on the same date, in corpus order. Each node gains due_on and days_late (0 is due that very day). The sum is that of kg_validate --due, so the two agree. What that report adds for x_term, x_mirror and x_doc_cycle stays there, and so does the list of nodes that only have a reverify_on. A node with reverify_every and no readable verified_at never comes due, and the hint says how many there are: that includes a verified_at with an hour, which is not a date in the contract. A cadence outside the form never comes due either, in both readers. On a graph that already fails the contract, the two lists can differ: a repeated id comes out once here, the first node, and a numeric id does not come out here, while kg_validate --due lists every line. --due is a filter: it combines with the others and with --group-by, but not with --sort impact, --sort attention or --semantic, which ask for another order (rc 2). The JSON output is kg-ssot.query/2, the /1 plus those fields; every other query stays in /1.

The --where of nodes has these forms, and every condition must hold:

Form Matches
KEY=VALUE The text of the value: x_n=12 matches the number 12, x_b=true the boolean. A list matches when one of its items does. A missing key does not match
KEY^=VALUE Text that starts with the value
KEY*=VALUE Text that contains the value
degree.in=N, degree.out=N, degree.total=N The count of the node's internal edges, a whole number
degree.in.TYPE=N (also out and total) The same count, for one edge type

The key is owner, an x_* field (a dot for a nested key), provenance.<key> or a degree; label^= is refused (rc 2), since --text and --id-prefix answer that. The --where of --edges takes ^= and *= on the edge fields, as from.id^=D_, and there the degree does not exist. A key that still ends in ^ * $ ! < > ~ | after the operator is read is refused (rc 2), for nodes and edges: x_n!=12 is not "different from", and there is no such operator.

  • Text operators. Case-sensitive, both sides in NFC. Only text matches: null, a missing key, a number, a boolean and .nan never do, as with Cypher's STARTS WITH and CONTAINS. A date is read as the text that writes it, as in a .kg.json, so x_when^=2026-11 matches. In a list, one text item is enough, which is the rule of = here (in Cypher a list gives null). An empty value matches any text and nothing else. The key ends at the first =, and after it the value is literal: x_f^=a=b looks for a=b, and owner=^a looks for ^a.
  • Degree. It counts the internal edges of the node's own graph, line by line: a repeated edge counts again, a loop counts in in and in out, and so twice in total, and an edge whose other end is missing counts for the end that exists. An external edge does not count, and neither does an edge that comes from another graph. It is not the degree of the attention formula, which follows the radar and counts other things. If a graph repeats an id, its lines add up in the first node, the only one the query keeps. The value is a whole number with =: degree.in=x, degree.in=01 and degree.in^=1 are refused (rc 2). An edge type that no internal edge of the corpus has counts 0, and the hint says so, because the zero of a typo must not pass for "there is none". The same holds for --group-by and --count-distinct. In --revisit, whose output has no hint, such a type is refused (rc 2). Every answer with the degree says in its hint that an edge from another graph does not count. So degree.in.SUPPORTS=0 means no internal SUPPORTS edge in the node's own graph, and no more than that: a SUPPORTS from another graph is not counted, and --edges --where edge_type=SUPPORTS --where scope=external lists those to complete the answer.
  • Presence. --has FIELD and --missing FIELD take any field of the node, with a dot for a nested key, up to 10 of each. A missing field and null are the same: they lack it. Empty text, 0, false, an empty list and an empty map have a value, as with Cypher's IS NOT NULL. The path does not go into a list. The same field in both is refused (rc 2), and so is any path that starts with degree, since the degree is derived and is not a field of the node. A path that no node of the corpus has with a value gets a hint, because --has trce would answer 0 and --missing trce the whole corpus.
  • Which queries. --due, --has and --missing filter the node queries: with --edges, --decision, --chain or --revisit they are rc 2. --where with a degree or an operator also works in --revisit.
  • Grouping by text. --group-by also takes label and narrative, with the whole text, and true and 1 stay in separate groups. With --min-count 2, they find the nodes with the same text. The group joins text in NFC and in NFD under one NFC key, and the node itself stays as it is. A node without the field falls in the null group, as in any --group-by: --has narrative leaves it out.
  • Group-by fields, for nodes. file, node_type, status, plane, layer, owner, label, narrative, an x_* field, provenance.<key>, meta.<key>, and degree.in, degree.out and degree.total, with an optional edge type.
kg-query --root . --due --type evidence --where owner=ana                      # what is overdue, on one owner's plate
kg-query --root . --due --as-of 2027-01-01 --group-by file                     # what will be overdue, per graph
kg-query --root . --type decision --status done --where degree.in.SUPPORTS=0   # finished decisions with no internal SUPPORTS edge in their own graph
kg-query --root . --group-by degree.total                                      # the histogram of the degree
kg-query --root . --type evidence --missing trace --has provenance.source      # evidence with a source and no trace
kg-query --root . --group-by label --min-count 2                               # nodes with the same label
kg-query --root . --edges --where from.id^=D_ --group-by edge_type             # what leaves the decisions, by a prefix

The MCP kg_query tool takes the same forms in where and group_by, plus due, as_of, has and missing, with the same refusals in the same words.

kg_diff.py: diff by id between revisions

Compares two graphs, or every graph that changed between two git revisions, aligning nodes by id and edges by their ends and type. Whatever exists on only one side was added or removed, and whatever exists on both sides comes out with each field that changed, before and after. Node order in the file does not count; an id change is a removal plus an addition; a list inside a node is compared as a whole; an edge that changes type with the same ends comes out as an edge_type change. The revision is read with git show, with no checkout. It is not a patch: the diff informs, it does not apply.

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

Real output, between two suite cases:

valid-node-touched-only-as-target.kg.yaml (modified)
  ~ meta.id: valid-chain → valid-node-touched-only-as-target
  + C_ALVO  Nó sintético C_ALVO.
  - C_B  Nó sintético C_B.
  + edge EV_A -SUPPORTS-> C_ALVO
  - edge C_B -SUPPORTS-> EV_A
· 1 graph(s): 1 modified, 0 added, 0 removed, 0 unreadable; nodes +1 -1 ~0; edges +1 -1 ~0

A field that leaves one path and enters another with the same value, in a node, an edge, or the meta, shows up as a rename (x_location → x_storage (renamed)) when the value is not trivial and is unique on both sides. In the JSON, both entries stay, each with the value at its own path, linked by renamed_to and renamed_from; the human text and the per-field summary show a single line. With --from and no paths, the vendor and the fixtures stay out (also a fixtures/ folder at the root, and, since 4.20.0, any folder with the .kg-ssot-version stamp on either side), as in kg_query: a vendor update changes the suite fixtures by the hundreds. --exclude and --no-default-excludes work the same way. The meta comes out together with the other root keys, values are compared in the contract's JSON form, a renamed file comes out as a removal plus an addition, and without --to (or with --to WORKTREE, since 4.20.0) the working tree is the after side, and the new graphs not yet added to git come in too. WORKTREE:PATH reads the working-tree file, from the repository root like REV:PATH and never outside it (git's ./ and ../, relative to the current directory, do not apply), to review an edit before the commit (HEAD:docs/x.kg.yaml WORKTREE:docs/x.kg.yaml); a branch named WORKTREE is read as refs/heads/WORKTREE. A side that cannot be read (not UTF-8, or does not parse) shows up in unreadable and counts as a difference; a revision that starts with - is broken input. In an argument, a path that exists on disk wins over a REV:PATH read.

--ignore FIELD (repeatable) removes a field from the comparison, together with everything below it (provenance removes all three; in the meta, meta.date): a verified_at that a generator restamps on every run hides the signal, and the summary counts nodes per changed field to show it. --format json gives kg-ssot.diff/1 (spec/diff.schema.json). rc 0 with no difference, 1 with a difference, 2 on broken input.

kg_vocab.py: the domain vocabulary

Reads the terms of the x_term profile (chapter 3) from a corpus, with --root or the files, and does four things: lists them, resolves a text or a code to its term, exports them, and checks the codes written in prose against them.

python3 -I -B vendor/kg-ssot/tools/kg_vocab.py terms --root . --as-of 2027-05-01      # terms, synonyms, what is due
python3 -I -B vendor/kg-ssot/tools/kg_vocab.py lookup "codigo de barras" --root .     # the term behind a text
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 .              # a code, qualified by scheme
python3 -I -B vendor/kg-ssot/tools/kg_vocab.py lint --root . --baseline .kg-ssot/vocab-lint.json --external soc2='CC[0-9]+'
  • lookup compares without case, accents, or repeated spaces. It matches the preferred term or a synonym. A synonym scoped to a case only matches with --case set to that case. A retired synonym still matches, marked as retired, and a superseded term comes with the terms that replace it. rc 1 when nothing matches.
  • terms shows when each synonym is due (verified_at plus reverify_every, on the --as-of date, today by default). A retired synonym is never due.
  • export --to skos writes Turtle in SKOS-XL with the ISO 25964 names (iso-thes). The concept gets skos:prefLabel, plus skos:altLabel and skos:hiddenLabel for the current domain synonyms, so a plain SKOS reader sees them. Each label becomes an skosxl:Label with its literal form and its provenance (dc:source, kg:locator, kg:sourceVersion, kg:verifiedAt, kg:reverifyEvery). A case-scoped synonym is linked by kg:caseLabel, with kg:scope and kg:case, and not by skosxl:altLabel, which would imply an skos:altLabel valid for the whole concept. A scope that cannot be read counts as a case that never matches. A retired one goes as isothes:status "retired" and an skos:historyNote. A synonym that repeats a literal already said in the concept goes by kg:repeatedLabel, so the concept never has the same label twice. A superseded term gets dct:isReplacedBy. The concept IRI is <base><meta.id>#<id>, the same one kg_prov_export.py gives the node (--base, urn:kg-ssot:graph: by default); without meta.id, the relative path given, or the file name, never an absolute path.
  • export --to ossie writes one item per live term for Apache Ossie: name (the preferred term), description, ai_context.synonyms (the current domain synonyms), and a custom_extensions entry from KG_SSOT with the provenance of each synonym, because Ossie has no field for it. You merge each item into the entity of your semantic model (dataset, field, or metric) by name.
  • The scheme and the code (4.23, contract v4.14). terms gives each term its effective scheme (the declared x_term.scheme, or the graph) and scheme_declared. lookup also matches a notation, exactly (a code is a code: o1 is not O1), alone or qualified as scheme:notation. export --to skos writes skos:inScheme for each concept and each scheme once as an skos:ConceptScheme.
  • lint checks the codes in prose against the vocabulary. It reads the label and the narrative of every node, finds the codes by a regex (--pattern; the default takes one to three capitals and one to three digits, like A7, O1, S14, and r with digits, like r16; it does not take RULE 87, SAC-234, or node ids, which have their own mechanisms), and reports:
  • vocab.notation-duplicate: the same notation in two live terms of the same scheme, across the corpus;
  • vocab.code-unresolved: a code that is the notation of no live term (a scheme:code looks only in that scheme);
  • vocab.code-ambiguous: a code without a scheme that is a notation in two or more schemes of the graph's own, or, with none of its own, in two or more schemes of the corpus. Write scheme:code.

A code resolves in this order: a term of the graph itself, then an outside system, then the corpus. The scheme of a scheme:code is a slug right before the code, on a word border (my_sa:T7 and urn:kg:E5 are not qualified). A declared scheme and a graph's own scheme (its meta.id) share one namespace, here, in the SKOS export, and in both readers. The rules for a duplicate are the reader's, and a term's own code resolves in its own label.

--external soc2='CC[0-9]+' (repeatable) declares the codes of an outside system (SOC 2, CVE, ISO) that resolve without a term. A regex that matches the empty text is refused. The ratchet is the gate's: --baseline <file> --update writes the findings of today, and from then on --baseline <file> fails only on a finding that is new or above the count in the base, so the codes that already exist do not block and the new ones are born with a name; a base written with another --pattern is refused. An occurrence the base already had in the other state of the same code (no term ↔ ambiguous, same file and code) comes out as reclassified and does not fail: naming the first term of a colliding code changes the state of the old mentions, and whoever names it does not pay for them. rc 1 with a failing finding or a graph that does not read, 2 on broken input. - With --format json, terms, lookup, and lint use the kg-ssot.vocab/1 format (spec/vocab.schema.json). The tool does not judge: an x_term outside the profile is read as far as it can be, and the SHOULD flags the rest.

kg_criterion.py: the pre-registered criterion (4.21.0)

The reader already warns, between nodes of the same graph, about a criterion that is not there, a cited sha256 that is not the one of the version, and a result measured after seeing the data (chapter 3, x_criterion and x_measured_under). It computes no hash and reads no corpus, so that a JavaScript reader stays free of crypto. This tool does the rest, from the repository root:

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                  # the whole file
python3 -I -B vendor/kg-ssot/tools/kg_criterion.py digest --ref data/judge-verdicts.json#CU1      # one list item
python3 -I -B vendor/kg-ssot/tools/kg_criterion.py check --root .
  • digest prints the sha256 to write in x_criterion. The forms:
  • text: UTF-8, with CRLF and a lone CR turned into LF, and without the space (U+0020) at the end of each line. A tab stays.
  • ref without a fragment: the bytes of the file with CRLF turned into LF, and nothing else. A TSV keeps its empty columns, with a tab at the end of the line.
  • ref with a fragment: the item in the canonical JCS form (RFC 8785), from a .json, .yaml, or .yml file. The fragment is a JSON Pointer (#/criteria/0) or the id of a list item (#CU1), unique in the file.
  • check checks every x_criterion with exactly one of ref and text against it:
  • integrity.criterion-digest when the sha256 does not match;
  • integrity.criterion-ref-unreadable when the file, the fragment, or a value does not read: a JSON NaN, an integer beyond ±(2^53−1), a repeated key, a YAML map key that is not text, or a path that is not a regular file or is larger than 64 MiB;
  • integrity.criterion-malformed when the version cannot be checked: the sha256 is not text (a hash of digits alone, without quotes, reads as a number), or there is not exactly one of ref and text, in text. An x_measured_under that is not a map with a list of citation maps, and a citation of another graph whose sha256 is not text (even when that graph was not given), come out the same way. Since 4.21.1; before, the check skipped them.
  • Citations of another graph. check also follows each <path>#<id> citation in an x_measured_under, with the same rule as the reader: integrity.criterion-unknown, integrity.criterion-sha-mismatch, integrity.measured-post-hoc, and integrity.measured-order-unknown.
  • What it reads. With --root alone, check reads the graphs git tracks under it, without the vendor. With files, it reads only those, each by its path relative to --root (the current folder without it), which is also where each ref starts, so g/a.kg.yaml#C1 finds the file however it was typed; a citation of a graph that was not given is counted as unchecked, not as a finding, and a given --exclude applies to the files too.
  • Paths. A ref cannot leave the root. The last # splits the path from the fragment, and an empty fragment (a.json#) is the whole document. A .yaml file needs a map at its root. A byte-order mark at the start of --text-file is not part of the text.
  • Output and rc. --format json gives kg-ssot.criterion/1 (spec/criterion.schema.json). rc 0 with no finding, 1 with a finding or a graph that does not read, and 2 on broken input.
  • What it does not check. check checks that each version matches its text today, not its history: editing a criterion in place and writing the new sha256 passes, and only a result that cites the old sha256 shows it. Review a changed x_criterion.sha256 in kg_diff (it shows as a changed field of the node): a new rule is an amendment, a new node with amends.
  • In CI. The reader does not hash, so a criterion edited in place without a new sha256 is caught only here: if your graphs use x_criterion, add python3 -I -B vendor/kg-ssot/tools/kg_criterion.py check --root . next to the gate.

kg_audit.py: the audit package by question (4.22.0)

An auditor asks for the package by question: the answer, the claims, the dated evidence, the gap, and the owner, in a spreadsheet. The graph already holds all of it; this tool lays it out, one row per questionnaire item, in CSV, XLSX, or JSON. It does not judge the form (that is kg_validate.py) and writes nothing to the graph.

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 package.xlsx
python3 -I -B vendor/kg-ssot/tools/kg_audit.py --root . --format csv --view evidence > evidence.csv
  • The item is the pair (questionnaire, number) that x_questionnaire names (name and questions). Who comes in:
  • the question: the question node with the profile;
  • the answer: a node (not question) with the profile for the item, and a node without the profile tied to the question by DEPENDS_ON, in either direction (both live in real graphs; the answer comes out once). A node with the profile has already said which item it answers, so an edge never pulls it into another item;
  • the evidence: a node with SUPPORTS to an answer, or with SUPPORTS or TRACES_TO to the question, to an evidence request, or to an evidence requirement of the item, local or from another graph (external_edges); a question, a request, or a requirement is never evidence;
  • the contradiction: a node with REFUTES to the answer or the question. Only a live answer brings evidence and contradictions: one that is superseded or refuted (a refutation already reconciled) neither closes nor opens a gap of the item;
  • the evidence request (role: evidence_request) and the evidence requirement (role: evidence_requirement, "what evidence this item requires", in its own column): never taken for a question or an answer. Each piece of evidence says which request it came from (the request it points to by SUPPORTS or TRACES_TO). An item that only a request or a requirement cites comes out too, with the gaps of what is missing.
  • The gaps are computed, never written by hand:
  • no-answer: no live answer (an answer that is refuted or superseded is listed, with its status, but does not count);
  • contradicted: a REFUTES from a node that is neither refuted nor superseded;
  • no-confirmed-evidence: no evidence with status: confirmed (evidence that is not confirmed is listed, but does not close a gap);
  • evidence-unstamped: confirmed evidence without a valid verified_at;
  • evidence-cadence-invalid: confirmed evidence whose reverify_every is not a duration, so it would never be due;
  • evidence-due: confirmed evidence whose verified_at plus reverify_every falls on or before --as-of (today by default);
  • verdict-open: no live verdict, or one other than meets and not_applicable.
  • Nothing drops in silence. A node whose x_questionnaire lacks name or a questions list that is not empty, a node whose role is outside the enum, and a number in questions that is not text (questions: [7] without quotes) do not come in: they come out in skipped, in the package itself (the Skipped sheet of the XLSX, --view skipped in CSV, the JSON), on standard error, and in the human listing, and --fail-on-gap gives rc 1 with them.
  • Your own columns. --column <key> adds a key of the answer node (an x_ key of yours, such as a list of remediation cards) as one more column. A list becomes items separated by ;.
  • The formats.
  • csv: the questions (one row per item) or, with --view evidence, the evidence (one row per item and piece of evidence, with the problems of its stamp), or, with --view skipped, what was skipped, in UTF-8 with a byte-order mark so that Excel reads it. A cell that starts with =, +, -, @, a tab, or a carriage return gets a leading apostrophe, so it never runs as a formula (the OWASP rule for CSV injection).
  • xlsx: the three sheets (questions, evidence, skipped) in one workbook, written with the standard library only, every value as text, the header frozen, and a cell cut at Excel's limit of 32,767 UTF-16 units. The same package gives the same bytes.
  • json: kg-ssot.audit/1 (spec/audit.schema.json), with everything nested.
  • human: a short listing for the terminal.
  • Output and rc. rc 0 when it exported, 1 when a graph does not read (the package would be incomplete in silence) or, with --fail-on-gap, when an item has a gap or something was skipped, and 2 on broken input. xlsx needs --output. Give the corpus with --root: an edge to another graph resolves by its path from the root.

kg_trace.py: the trace that does not resolve (4.25.0)

A node's trace: says where its "why" lives. The reader checks that it is there, never that it still points to something: the reader does not touch the disk, so it runs anywhere, in JavaScript too. A trace: that points to a moved or renamed file passes the gate and misleads whoever goes back to it. This tool checks the anchor from the repository root, as kg_criterion.py checks a 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
  • What it reads. The .kg.yaml and .kg.json files that git tracks under the root, outside the vendor, docs/materials/, and the fixture folders (fixture/, fixtures/, __fixtures__/, testdata/, __snapshots__/, at any depth). --exclude adds patterns, and --no-default-excludes drops the defaults. A trace that is not text is counted as not_text; a node with a repeated or non-text id, which the gate already fails on MUST, is not read here. If your graphs anchor in a folder outside the repository (a session's memory), the check stays red until you declare it with --external-root. With no graph read, it says so.
  • What is judged. The text of the trace up to its first : or # (file:line, file#section), and only when it looks like a path of the repository. Not judged, and counted apart, never in silence:
  • absolute: an absolute path or a URL (another machine or network);
  • external_root: a prefix you declare with --external-root (a folder that lives outside the repository);
  • not_path: a character outside [A-Za-z0-9_./-] (a command, prose), a name without a slash (a loose word, a config key), or a first segment with a dot that does not start with one (a hostname, support.example.com/...).

A name without a slash comes in only when it is a file tracked at the repository root with the exact name (ARCHITECTURE.md), and no file of the same name sits next to the graph or in its parent folder. ./x and x are the same target. - Where it resolves. At the repository root, in the graph's folder, or in the graph's parent folder (only when the graph is not at the root). A target that leaves the root (../sibling/x) does not resolve: the CI clone does not have it. - Output and rc. Each target that does not resolve is integrity.trace-target-missing. --format json gives kg-ssot.trace/1 (spec/trace.schema.json). Since 4.25.1 it also lists resolved_targets: for each trace that resolves, the file and the node id, the judged target, the path of what the check saw exist, relative to the root and with links followed (so a link followed by .. cannot point to another file), and its kind, file or dir, which is what a read index needs. When the trace goes through a link, path is the real destination, not the text (on Windows the system resolves .. on the text before the link, so link/../x is judged as x); a trace that lands on the root itself is the directory .; path never ends in a slash, not even for a directory (kind says it is one); and path keeps the case that the file system returns. The field is additive, and the output of 4.25.0 stays valid. rc 0 when every judged trace resolves, 1 with a missing target or a graph that does not read, 2 on broken input, and 3 when no graph was read (since 4.27: an empty corpus is not "every trace resolves" for a CI that reads only the rc). These rules come from an adopter's resolver measured on thousands of traces; each cut was paid for by a false positive.

The JSON locator (4.35.0)

When a node points into a JSON file, its provenance.locator can be a JSON Pointer in fragment form (#/color/brand/primary, RFC 6901), and kg_trace check follows it inside the file, so a token or a key that disappears from the JSON is caught, not only the file.

  • What is judged. A provenance.locator that is # or starts with #/, against a .json file of the repository: the path of provenance.source when it is a path of the repository, otherwise the path of trace:. A source path that no longer exists is integrity.trace-locator-missing itself: the locator is never judged in another file. For the source, a bare file name with an extension (tokens.json) is a path, since a source is not prose; a source that leaves the repository root (../x.json) is counted as outside_root, not judged. The fragment loses its percent-encoding, then ~1 becomes / and ~0 becomes ~; an object is followed by key and a list by a decimal index with no leading zero.
  • Not judged, counted: a locator that is not a pointer (not_pointer), a source given by URL or absolute path (absolute), and a pointer with no .json file of the repository to read (no_json_file).
  • Output and rc. A pointer that does not resolve is integrity.trace-locator-missing, and a JSON file that does not read is integrity.trace-locator-unreadable; both give rc 1. The JSON output gains locators, additive and outside required, so the output of 4.25 stays valid. Write the path in trace: too: that is what says the file itself is gone.

The locator by scheme, and the hash of the source (4.37.0)

When a node declares provenance.scheme or provenance.sha256 (contract v4.17), kg_trace check opens the source and checks them.

  • The source. The file of the repository that provenance.source names, or, with --with PREFIX=FOLDER, the file that a URL names at the commit of the URL (<prefix>/<commit>/<path>), read from a local clone of the other repository with git show. The folder must be the root of a git clone, and the reference in the URL must be a commit name: a commit that the clone does not have, or a folder that is not the root of a clone, is missing_source, and a branch or tag name (main, HEAD) is unpinned, since it moves. A pinned source is never checked against another version. A URL with no --with that matches is counted as absolute.
  • The hash. provenance.sha256 against the content of the source as git stores it: a tracked file with no change since the index is read from its blob, so a checkout that converts line endings (core.autocrlf, the git default on Windows) gives the same hash as on Linux, and the same as git show <commit>:<path> | sha256sum. A file with a change, or outside git, is read from disk. A difference is integrity.trace-sha256-mismatch, the source changed after it was read.
  • The locator. text (the line or character range exists, characters counted as code points and each line ending as one, as RFC 5147 asks), csv (the row, column or cell exists, the column of a cell against its own row), quote (the exact text appears, with runs of spaces counted as one), kg-node (the node exists in the graph) and json-pointer (it resolves in a .json source, in both forms of RFC 6901). Out of range is integrity.trace-locator-out-of-range; a quote that is not there, integrity.trace-quote-missing. Not judged, and counted, never as ok: pdf, media and html (form_only, judged by form in the schema), a locator outside the grammar of its scheme, a scheme outside the table or a sha256 out of form (malformed, which the schema already warns about), a JSON Pointer on a source that is not JSON (not_json) and a source that is not UTF-8 (not_text). A pointer judged here is not judged again in locators; one that could not be judged here (a source that is gone, an anchor in trace:) still is, as in 4.35.0.
  • Output and rc. Each failure gives rc 1. The JSON output gains schemes, additive and outside required.
kg-trace check --root . \
  --with https://github.com/acme/core/blob=../core

The hash of a cited node, and digest (4.38.0)

With scheme: kg-node, provenance.node_sha256 (contract v4.18) is compared with the canonical hash of the cited node, read at the pinned commit: a difference is integrity.trace-node-sha256-mismatch, and the JSON output counts node_sha256_checked. A change in another node of the same file does not touch it. kg_trace.py digest FILE#ID... prints the hash to record; the JavaScript reader exports nodeCanonical(doc, id), the same canonical string, to hash with the platform's SHA-256. The hash covers the whole cited node, verified_at included, so a re-stamp of the cited node breaks the bridges to it, which is the point. A cited node that repeats, or has no JCS form (a number with an integer value outside ±(2^53−1)), has no hash to check and counts as not_canonical. When a bridge carries both hashes, the file sha256 is checked first and a change in a neighbor still breaks it: on a kg-node bridge, keep only node_sha256. digest reads the working tree under the root; for a pinned source, read the file at the commit (git show <commit>:<path>) first.

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

With --format json (4.39.0), digest returns kg-ssot.digest/2 (spec/digest.schema.json; /2 since 4.40.0): one result per reference, with node_sha256 and a status of ok, not_found, file_missing, unreadable or no_canonical_form, and the same exit code as the text output. node_sha256 is present with ok and null otherwise. Since 4.40.0, a file that does not exist is file_missing (text: FILE MISSING), and a file that is not UTF-8 is unreadable with code encoding.not-utf8 (or io.unreadable, when it exists but cannot be read): each is the result of its own reference, and the batch goes on. Since 4.39.1, a graph that does not parse is unreadable, with the reader's code, and no longer not_found: the node was not looked for, so it is not missing. Read the value from the field instead of splitting the text line. Only a broken call stops the batch before any result, in both formats, with rc 2: a path outside the root, or a reference without FILE#ID.

The projection checker (kg_trace.py projection, 4.35.0)

A context page in Markdown (business, technical, design) can declare that it is a projection of graphs: projection_of in its front matter lists the graphs, and the page cites node ids between backticks. The checker makes that discipline verifiable.

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   # another repository, pinned to the commit
projection_ignore:   # names between backticks that are not node ids
  - BUILD_TOKEN
---
  • What is read. Every tracked .md whose front matter has projection_of, or the pages you name. A page named without projection_of is counted as no_projection; in the scan, a front matter without it is not read at all.
  • What counts as a cited id. Text between backticks shaped like an id (D_SOMETHING, E_X_1027), in the prose or inside a code block alike. An id in running text, outside backticks, is not read; a page that declares projection_of and cites no id between backticks is listed in uncited, with a warning, since nothing was checked. The contract's own words (edge types such as TRACES_TO, enum values) and the names in projection_ignore are counted apart.
  • Four buckets. resolved (in a graph of projection_of); elsewhere (only in another graph of the repository: a warning, not a failure); outside (projection_of names a graph of another repository by URL that --with did not read: not judged, counted); missing (integrity.projection-id-missing).
  • --with PREFIX=FOLDER reads that other graph from a local folder, at the commit in the URL when the folder is a git repository that has it, as in kg_query --chain. A URL that --with covers and that does not read (absent at the ref, not UTF-8, not a graph) is a graph error (rc 1), never outside. A graph read from the working tree because the ref of the URL is not in that folder is listed in unpinned, with a warning.
  • Output and rc. --format json gives kg-ssot.projection/1 (spec/projection.schema.json). rc 0 with no missing and every graph of projection_of read; 1 otherwise; 2 on broken input (a front matter that does not read, a --root that is not a folder).

The sources that are absent: --strict-sources (4.44.0)

A source with a provenance.scheme or a provenance.sha256 that is not there, in the repository or at the commit of a URL read with --with, is counted in skipped.missing_source, and check still gives rc 0: that stays, for compatibility. --strict-sources lists each one (SOURCE MISSING) and gives its own rc, 4, after any finding of rc 1, and the JSON output is kg-ssot.trace/2, with schemes.missing_sources. A source with neither is not judged, with or without the option, and that includes a URL with a commit and no hash: to compare those, use kg_refs.py pins.

A provenance.source in the form repo#id@commit is not read here, and kg_refs.py pins is what compares it. When the form occurs, with or without the option, it is counted in a line of its own in the human output and in schemes.repo_refs, and the JSON output is kg-ssot.trace/2. A corpus without that form and without the option gets the same kg-ssot.trace/1 as before.

kg_refs.py: citations across repositories (4.44.0)

Who cites this node, before you delete it? And what changed in what you cite? A pinned citation stays valid at its commit, so nothing warns the graph that cites it. This tool asks, over a list you declare, and it is a query, not an alarm: a change never alters the exit code, and no option makes it.

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  # an earlier state
kg-refs pins --repos repos.txt --ignore '*' --format json

The list is --repo NAME=FOLDER, repeatable, or --repos FILE, with one NAME=FOLDER per line (# comments, and a relative folder starts from the file). Without a list the tool refuses (rc 2): it does not guess repositories. An alias is one more name for the same folder: when your graphs write a repository in two ways, repeat --repo (--repo billing=../billing --repo billing-old=../billing), and both names resolve to the same clone, read once. --known NAME, repeatable, declares your network, with a folder or without one: a name that also has a folder in the list is simply covered, with the owner written or not. A folder is read only from git objects, at its HEAD or at the commit of --at, without network and without the working tree, so what you have not committed does not count. The graphs follow the file rules of kg_query (the vendor and the fixtures stay out; --exclude, --no-default-excludes). Only node fields are read.

The forms it reads, in any node field:

Form Example
URL, including raw https://host/owner/repo/blob/<commit>/path.kg.yaml#ID
Owner and repository owner/repo@<sha>:path
Repository and commit repo@<sha>:path#ID, or repo@<sha>#ID
Repository, path, commit repo:path@<sha>#ID
Without a path repo#ID@<commit>, with provenance.node_sha256 if there is one
Source and locator provenance.source with the commit, and the id in provenance.locator

Without a path, the id is searched in the graphs of that commit, and two nodes give ambiguous, never a guess. In a file that is not a graph, # is a section, not an id. An id or a path that only appears in the prose next to the citation, and exists at that commit, is marked inferred; an id that also exists in the repository of who cites does not enter: there it names the node of that repository. A citation without a commit (a branch in a URL, repo:path) cannot be compared and is counted in unpinned.

cited-by REPO:ID lists each citing field, with its form and certainty: explicit (a citation of the node, or an edge in its repository), same_repo or by_id (a mention by id), local_homonym (a mention, and the id also exists where it is written, so it may name the node there), and contains_file (a citation of the whole file that contains the node, in the state read). A citation of the same id in another repository of the list is counted in elsewhere; a mention next to a citation of something else stays a mention. Every pinned commit of a citation of the node or of its file is checked. target.found_in empty means the node is not in that state: --at reads an earlier one, and the citers still appear.

pins compares each citation, at its pinned commit, against the tip, and hides nothing: summary.groups only says what to look at first, gone and status at the top, and file appears like the rest. --from REPO lists only the citations written in that repository, and --status gone,changed lists only those statuses. Both only cut the list, and say so in a line: the scan, summary and the rc stay those of the whole list. The edges of a node are compared across the whole repository, at both commits, so an edge that another graph adds, like a REFUTES, gives changed edges, and a node that moved gives same, even when an edge now writes it as path#ID or stays in the graph it left.

Status Meaning
same Nothing changed in the node or the file, even if it moved (moved_to)
changed With classes: status, text (a change only in spaces is here, not in stamp_only), edges, other, file (a citation of a file without a node), stamp_only (only verified_at and the fields of its axis). A node also brings current_status, its status today
gone Not at the tip
ambiguous More than one node has the id, at the commit or at the tip; candidates lists them
unchecked With reason: commit_missing, tip_behind_pin (the local tip is older than the pin, so comparing would accuse in the wrong direction), no_access (the form repo#ID@commit of a repository that counts and has no folder here), no_target (the whole repository at that commit, with no id or path next to it that exists there), path_absent_at_commit, id_absent_at_commit, hash_mismatch_at_commit, no_graphs (a node of a repository with no graph at the tip, or a file that is gone from a tip with no graph and the pin outside its history, which proves nothing about either), a graph that writes the id and does not read, at the commit or at the tip (graph_unreadable_at_commit, graph_unreadable_at_tip), or objects missing there, as in a partial clone, so nobody can say which graph writes the id (objects_missing_at_commit, objects_missing_at_tip). A graph that does not read and does not write the id changes nothing

relation says how the pin relates to the tip: on_history, or apart (outside its history, as after a rebase, and still compared). The hash is the node_digest of kg_trace.py digest.

Coverage is the central rule, because an empty answer does not prove absence. Every output has scanned and not_scanned, with the reason, and rc repeats the exit code:

rc When
0 The scan of the declared list was complete, and everything cited was checked
2 Usage error, including no list
3 Partial: a folder missing, not the root of a clone, or without the commit; no graph read where one was needed (no_graphs: the file rules took them all out, with --exclude '*' or only fixtures/; --at read a state with none; the repository of the cited-by target, or one that pins cites by a node, has none; or the whole scan has none; an ordinary member, not cited or cited only by a file, of the list with no graph at all is not partial, and stays in scanned with graphs: 0); a commit or a graph that does not read; a pinned commit missing, in any form (in a shallow clone, detail: shallow); a --known repository, which has no folder; or a name cited with a commit that is not in the list
4 Only in pins, on a complete scan: something is ambiguous or unchecked

A name outside the list counts only with a commit. With a path or an id, as in billing@<sha>:path, it is a repository. A bare name@<sha> needs an owner in front (not a dot and not a remote, as in origin/main) or a hyphen in the name, so 53@<sha> and tools@<sha> are just text. Then there are three ways. Without any option, it enters not_scanned, which is the safe path: you find out who you did not read. With --known, only the list and the known repositories count in the rc: the rest goes to mentioned_undeclared, with its count, and each citation with an id goes to outside_citations, with who cites, the field and the id, without changing the rc. --ignore NAME takes out a false one, and takes a glob; --ignore '*' declares the list complete. Coverage wins over rc 4. In a corpus with whole-repository citations (repo@<sha> with no target), pins gives rc 4 for those.

The human output puts what the list declared and failed first, in a block of its own, and the names outside the list in one line with the count; the names are in the JSON.

The limit: it finds a wrong citation, not a missing one. It reads what the graphs wrote, in the folders you listed. A claim of absence that cites nothing does not appear, nor does a citation outside a node field, in a comment or in a file that is not a graph. It says nothing about a repository you have no folder for, and it has not been tried by a third party without access to them. It does not make anyone run it. The MCP server does not have it: it reads other folders.

Five more limits, so you do not read more into the answer than it says:

  • Two homonyms at the other end of an edge count as one. That end is compared by id only, so an edge of the node that starts or stops pointing to a node with the same id in another file does not change the verdict of the node.
  • An ordinary member on the wrong branch looks like a member with no graphs. It stays in scanned with graphs: 0 and rc 0: look at scanned if you expected graphs there. Only the repository of the target and --at give rc 3 for it.
  • Identity is by name only. A fork with another owner and the same repository name resolves to the folder of that name; the tool compares commits, it does not check the owner.
  • Empty with rc 0 means among the listed ones. It is a complete answer about the list you declared, not about the world. Rc 0 says nothing about a repository outside the list, or one you took out with --ignore. In cited-by, a repo@<sha> with no path and no id stays out too, even with its commit missing: nothing ties it to the node.
  • A graph that was reordered as a whole gives changed file, without changing the rc. A citation of the file compares the object, so it does not know the nodes are the same; a citation of the node gives same, because it compares the node.

kg_vector.py: the model and the index of the semantic search (4.26.0)

kg_query --semantic finds a node when the question uses other words than the label. It is optional: it needs the vector extra (pip install "kg-ssot[vector]", which brings onnxruntime, tokenizers and numpy; in the vendor, install tools/requirements-vector.txt with --require-hashes). Without it, the kit runs as always, and only --semantic and this script refuse, with rc 3 and rc 2 and what to install. Nothing here enters a verdict: the contract, the gate and --text do not depend on the vector.

python3 -I -B vendor/kg-ssot/tools/kg_vector.py fetch                 # the model, once (about 250 MB)
python3 -I -B vendor/kg-ssot/tools/kg_vector.py index --root .        # the cache, redone only where a node changed
python3 -I -B vendor/kg-ssot/tools/kg_query.py --root . --text "busca vetorial" --semantic "vale a pena adotar busca vetorial?"
  • The model is an ONNX file with its revision and sha256 pinned in the script (kg_vector.py models): the default is the multilingual model that the bench measured (about 50 languages, 128 tokens). fetch downloads it from Hugging Face into $KG_SSOT_MODEL_DIR (or .cache/kg-ssot/models in the home folder) and checks the sha256 before putting it in place; each load checks it again, and a query never goes to the network. Without network, put the files there.
  • The text of a node is its label and narrative joined by a space; the vector is the mean of the tokens, normalized. It reproduces the bench's embedder: on its 403 nodes, the smallest cosine was 0.9999999.
  • The index is a cache in <root>/.kg-ssot/vector/<model>/index.npz, with a .gitignore that keeps it out of git: one vector per node and the sha256 of the text that made it, in a single file replaced at once and checked on load (shape, dimension, finite values, unique keys); an index that fails the check is ignored and rebuilt. A node edited after the index does not keep an old vector: the query embeds it on the spot and counts it in embedded_now, and the next index redoes only what changed. Without an index the query embeds every candidate (on the bench's 403 nodes, about 15 s; with the index, about 2 s). On a corpus of 6,385 nodes, the first index took about 10 minutes on 8 cores, the next one 7 s, and a hybrid query with the index 7 s. Give index and the query the same --exclude: a node the index left out is embedded on the spot at every query.
  • In the MCP server, kg_query takes semantic when the server has the extra and the model; otherwise the tool answers with an error that says what is missing, never with text alone. The server has no index on disk: it embeds each node once per process and keeps the vector in memory by the sha256 of its text, so the first semantic call on a large store is the slow one.
  • A node without text (no label and no narrative) goes to the end of the vector order: the vector of an empty text says nothing about it. The MCPB bundle, the GHCR image and the npm package do not carry the vector: it is the PyPI package with the extra, or the vendor with tools/requirements-vector.txt.

rc 0 done; 2 on broken input, a missing extra or model, a file that does not match, or a failed download.

kg_prov_export.py: provenance in PROV-O

Exports a .kg.yaml to PROV-O in JSON-LD, for anyone who already uses tools from the W3C PROV ecosystem. Each node becomes a prov:Entity. A node with provenance gets prov:wasDerivedFrom pointing to the source and a prov:Derivation whose activity carries the method; locator and locality go in as kg: properties. A source that is entirely one http(s) URL becomes the IRI itself; any other source (a path, a command, two URLs together) becomes a labeled blank node, one per distinct text, so nodes with the same source point to the same entity.

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

An excerpt of the output, the node with 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"
   }

What it does not export, on purpose:

  • The edges, by default. SUPPORTS, REFUTES, and the others are evidence relations, and derivation in PROV is a production relation. Exporting them as wasDerivedFrom would say something the graph does not say. With --edges they come out in a different way, described below.
  • The stamp as a generation time. verified_at comes out as kg:verifiedAt (typed xsd:date when it is a date), not as prov:generatedAtTime: in PROV, the generation time marks when the entity came into existence, not when it was verified.

The edges, with --edges

--edges exports every edge, from edges and external_edges, without loss and without a false derivation:

  • Every edge becomes a kg:Edge resource. It carries kg:edgeType, kg:from, kg:to, the label as rdfs:label, and any other key (trigger, x_*) as kg:<key>, with the key name percent-encoded so that an x_ key with a space still gives a valid IRI. A value that is not text goes as JSON, typed kg:json. So any type, including the domain ones, can be rebuilt from the RDF.
  • An external end (<path>#<id>) goes as the literal kg:fromRef or kg:toRef, because the IRI of a node in another graph depends on that file's meta.id. An end that is not a valid node id (input the contract rejects, such as an absolute path) also goes as a literal, so it never becomes a broken IRI that each RDF reader reads its own way. A missing from (the whole graph) goes as kg:fromGraph true.
  • Between two local nodes, three types also get a standard triple:
  • SUPPORTS gets cito:supports, from CiTO;
  • REFUTES gets cito:refutes;
  • SUPERSEDES gets dcterms:replaces, from DCMI Terms.

The other types get none, because PROV has no pair for any of the three and no standard term was found for the rest. - No edge produces prov:wasDerivedFrom.

Without the option, the output is byte for byte what it was before. Before release, the mapping was measured on 213 graphs and 9,988 edges against criteria registered in advance. Every edge was rebuilt from the RDF read by rdflib, and the cost was 1.7 times the plain export.

The whole document, with --full

--full implies --edges and also exports every field of every node and of meta, so the loaded YAML can be rebuilt from the RDF:

  • Every field of every node goes as kg:<key>, including the ones that already go out under another term (label, status, provenance...). Text goes as a plain literal, and any other value (number, boolean, null, list, map) goes as JSON, typed kg:json.
  • A kg:Graph resource at <base><meta.id> carries each meta key as kg:<key>, and each extra top-level key (the x_*) as kg:top-<key>.
  • A value that does not fit in JSON makes the exporter exit with rc 2, naming the field.

The order of the items and the surface form (quotes, comments, style) are not kept. Before release it was measured on 209 graphs, 7,778 nodes and 9,906 edges, against criteria registered in advance. The loaded YAML came back equal from the RDF in every graph, and exporting the whole core corpus took 0.25 s and 19.7 MB on a CI runner.

To read the output in RDF tools, install the rdf extra: pip install "kg-ssot[rdf]" (rdflib), or, in the vendor, tools/requirements-rdf.txt with --require-hashes. It stays out of the gate profile, and the exporter itself does not need it. The CI reads the --edges output of the whole suite and of the repository's graphs with rdflib and rebuilds every edge from the RDF alone:

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

The exporter does not validate the graph: run kg_validate.py first. --base IRI changes the prefix of the node IRIs (the default is urn:kg-ssot:graph:, and each node sits at <base><meta.id>#<id>); the kg: vocabulary lives at urn:kg-ssot:vocab#, outside that space. rc 2 when the file is unreadable, is not a single YAML document, or has a malformed meta or nodes.

kg_migrate.py: the profile-driven migrator

Proposes the provenance that contract v4 requires on confirmed or PROD nodes that do not have it yet, from a profile that the adopter writes and versions: an ordered list of rules (spec/kg-migrate-profile.schema.json), where the first rule that matches decides. There is a commented example in spec/migrate-examples/company-brain.profile.yaml, measured on a real corpus of 25 graphs and 1,024 nodes.

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
  • plan writes nothing. It prints the counts per rule and, with --out, writes the proposal sheet, one row per node, with what goes to the reviewer and why. Read the sheet before you run apply.
  • Each rule does one of three things: propose the provenance, demote the node to unverifiable (the source is unrecoverable, which is the contract's default), or only send the node to review. A node that no rule matches also goes to review and gets nothing.
  • when matches on what the node already carries: the trace (empty, URL, path, glob, existence in the tree, regular expression), the type, the status, the plane, verified_against, source_kind, and the SUPPORTS edges that reach the node, with not and any_of.
  • Templates use the node's fields: {trace}, {trace_path}, {trace_line}, {verified_against}, {supports}, and {source_kind}, and {a|b} takes the first one that is not empty. The profile's note goes at the end of every method detail, for example não reverificado na migração de 2026-10-20 (the note in the example profile).
  • scopes gives verified_scope only by a list of ids, never by a text pattern.
  • The profile chooses within the contract and never relaxes it. Every proposal is checked against the MUST schema and the SHOULD form schema before it becomes a proposal. A proposal that would fail goes to review with the reason, and so does a template that resolves to empty. A coherence warning that the proposal raises does not block it, because the warning tells the truth about the node. For example, a testimony on a PROD node raises integrity.testimony-in-prod: the proposal goes ahead, and the node goes to the reviewer with the warning named.
  • apply writes file by file, as text, and comments stay. Afterward it rereads the file and checks the invariance: each node stays as it was, except the keys the action authorizes, and the diff only adds lines, except the line of a demoted status. A file that would gain a new MUST code, or a SHOULD warning that the plan did not announce, stays as it was. The file's line ending is kept (CRLF stays CRLF), and a file with mixed line endings is refused. Running it again changes nothing.
  • Without a list of graphs, the tool reads the .kg.yaml files from git ls-files. It leaves out the kg_gate.py patterns (*/fixtures/* and docs/materials/*), vendor/*, any folder with the .kg-ssot-version stamp (since 4.20.0), and the --exclude globs, with the gate's semantics: fnmatch on the relative path, where * crosses /; the profile dialect's **/, which matches zero folders, also works. --no-default-excludes removes the patterns. Note: in the profile's trace_path_glob, * does not cross /. A listed graph that does not parse becomes a review row and does not abort the run.
  • A node whose verified_scope already matches the profile comes out as same, separate from the nodes that change, and apply does not touch it.
  • The trace path is lexical and stays inside the repository root. It is normalized (./, a/../b), and a path that leaves the root with ../ stops being a path. An absolute path or one with ~ counts as nonexistent, without looking at the machine's disk.

What stays out, on purpose, because it is judgment: rewriting a label, changing a plane, re-measuring, refuting, reconciling with SUPERSEDES, and promoting an unverifiable node. rc 0 when the plan comes out or apply finishes; rc 1 when apply refused a file, which stays as it was, with the reason in the report; rc 2 on broken input (a profile outside the schema, a repeated rule or id, an invalid regular expression, a when that would match every node, a graph given on the command line that does not parse). The migrator travels in the vendor, not in the PyPI or npm package.

keys: a foreign key becomes x_<key> (4.19.0)

python3 -I -B vendor/kg-ssot/tools/kg_migrate.py keys            # the plan: what would be renamed, and what goes to review
python3 -I -B vendor/kg-ssot/tools/kg_migrate.py keys --apply    # writes the renames

A key the contract does not know fails in the MUST. keys renames it to x_<key>, the extension prefix, in the top level, meta, nodes, edges, and external edges, by text, so comments stay. The known keys come from the MUST schema of the release, never from a list in the code. A key goes to the reviewer instead when it is not a slug, when x_<key> already exists in the same mapping, or when the new name is a canonical profile (severity would become x_severity, whose value the profile judges). With --apply, each file is written only if the reread document equals the old one with only the renamed keys, the diff touches only those lines, and the file gains no MUST code or SHOULD warning. The line ending is kept, a file with mixed line endings is refused, and running it again changes nothing. Measured on a copy of the 15 graphs of the core that failed the MUST on a foreign key: with meta.title known since v4.11 and 76 keys renamed in 12 files, 14 of them pass; the one left also lacks provenance. Run by the core on its own corpus (2026-10-23), with the gate's excludes: the gate went from 170 to 182 of 184 graphs in the MUST, and the inherited debt from 14 graphs to 2, both lacking provenance.

nest: your own x_ keys become a canonical profile (4.21.0)

python3 -I -B vendor/kg-ssot/tools/kg_migrate.py nest --map nest-map.yaml            # the plan
python3 -I -B vendor/kg-ssot/tools/kg_migrate.py nest --map nest-map.yaml --apply    # writes the profiles

keys gives a foreign key the x_ prefix; nest goes one step further and moves the x_ keys an adopter invented into the canonical node profiles of chapter 3, x_mirror, x_doc_cycle, and x_questionnaire. The map (spec/kg-migrate-nest.schema.json) has one rule per profile. A rule applies to the node that has its when key, and each field of the profile comes from one source: const (a fixed value), move (an x_ key that leaves the node), or copy (any key, which stays, such as verified_at). A source can go through match (a regex with one group, read as ECMA-262 reads it: \w and \d in ASCII, \s with the Unicode spaces, . without line breaks, $ only at the end of the text; what the two engines read differently is broken input, and a value with a character beyond U+FFFF goes to the reviewer) and through a value table, values for exact values and prefixes for the longest matching prefix. With raw: true, the original value of each moved key that had text beyond the code (it came in by a prefix, or the regex cut part of it) stays in <profile>.x_raw; an exact value of the table does not. A key moved through match or prefixes loses the text the code leaves out, so such a rule needs raw: true (the map is broken input without it):

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: {in force: in_force}, prefixes: {"in force (": in_force}}

Nothing is guessed. A node goes to the reviewer, untouched, when a value has no code in the table, when the source of a required field is missing, when the node already has the profile, when the profile it would get fails the profile's SHOULD schema (a synced_at that is not a date, a state outside the enum), when the node is written in flow style ({...}), when its when key is null, when a value is .nan or .inf, or when the text edit of that node would not reread the same (a moved value with an anchor &x or an alias *x). The plan tries the text edit of the whole file once, and splits it only when it fails, so a node it cannot edit goes to the reviewer there, and --apply writes the others. --apply writes each file under the same checks as keys: the reread document must equal the old one with only the moved keys and the new profiles, and the file must gain no MUST code or SHOULD warning. The line ending is kept, comments outside the moved keys stay, and running it again changes nothing. Measured on a copy of an adopter's two graphs (108 nodes with 168 such keys): 106 profiles built, 2 left for the reviewer (two x_in_force values with free text the map did not cover), both files written with the same verdict as before.