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).
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:
KG_SSOT_LANG, the override for KG-SSOT alone (pt-BR,pt_BR, oren);LANGUAGE, a colon-separated list (pt_BR:en), where the first language with a catalog wins;LC_ALL, thenLC_MESSAGES, thenLANG(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.
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:
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:
codeis the vocabulary of the suite and the gate, and a tool decides by it.messageandhintare text for people: they follow the output language and may change. - Where the occurrence is:
pathpoints into the document (nodes[7].provenance.method) andlineis the line in the file, in.kg.yamland in.kg.json, so an editor can fix the right spot without rewriting the graph. - The cutoff:
summarycounts every occurrence.occurrencesholds up to--limit, andtruncatedandtotaltell you whether the list was cut. That way a large graph does not flood a model's context. - The contract that judged:
contractholds 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\Uescape, 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.
The last lines:
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:
rc 2. The first baseline:
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
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.
| 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:nullwhen the write happened."gate"means the contract failed the graph, and theverdictsays where."conflict"meansexpected_sha256does 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 outsidespec/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"witherror: "rejected by the external gate"means the contract passed and the outside gate failed the graph; theverdictis the external gate's.- Optimistic locking:
expected_sha256is the sha256 of the head the writer read, orNEW(on the CLI,new) to require that the name does not exist yet. Writing the same text as the head returnswrittenwithunchangedand no commit. - The path: the name is relative, ends in
.kg.yamlor.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_fdwithO_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 withlstat(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 onelstatsaw, 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, withref,op(writeordelete), author, date, and message.read(name, ref=...)reads an earlier version: the commit sha in git, the eventseqin the versioned store. - Isolated git:
GitStorereads neither the system config nor the user config (no hooks, no gpg, noautocrlf, 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 bykg_mcp.pygetsKG_SSOT_LANG=en, because its verdict goes to the model; run bykg_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_NAMEandKG_GATE_OP(writeordelete). The command inherits the rest of the process environment, except thekg_mcpsecret variable (--secret-env). - Output: a
kg-ssot.verdict/1document that is valid underspec/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., oryaml.); 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-timeoutwithout--gate-cmdand, inkg_mcp.py,--gate-cmdwith--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
shlexrules (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
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
--authoror, 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 theinputSchemathattools/listannounces: a wrong type, a missing required field, and an unknown field come back asisError. 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-errorbefore 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.1by default. It refuses anOriginwhose host is not loopback (against DNS rebinding), an unknownMCP-Protocol-Version(400), and a body withoutContent-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 au-<name>-<hash>directory, and cannot see anyone else's. Its writes are signed in the formlocal-part <email>. The email address is not case-sensitive:Ana@X.organdana@x.orgare 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-graphsapplies 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-onlyan 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_dataenvelope:kg_get,kg_list,kg_history,kg_query, andkg_termsreturn{"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>shipskg-mcpready 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/datavolume. It carries theio.modelcontextprotocol.server.namelabel, 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:/dataand--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
allowedDomainslist that is not empty is closed: only its domains pass, for every MCP server reached by URL. If yourlibrechat.yamlalready has other servers, put their domains in the same list, or they stop working.The
--host 0.0.0.0is 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 notinternal, so the container can still reach the internet). WithoutKG_MCP_SECRET, the container exits with rc 2 and serves nothing. The secret lives in the compose.env, never in the versionedlibrechat.yaml. Like any container environment variable, it shows up indocker 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,/datastarts 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 andPermission denied. - The platform: the image ships only forlinux/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 (seekg_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--rootto only judge. - What stays with the adopter, on purpose: TLS, the OAuth profile, per-user quotas, and per-tool timeouts. - A client that cachestools/list: some clients, such as LibreChat, validate arguments against thetools/listthey cached at startup. After an update that changes a tool'sinputSchema(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 whenevertools/listchanges. - The two protocol eras: the server speaks both, and each request picks its era. - The new era,2026-07-28, stateless: every request carries inparams._metathe version (io.modelcontextprotocol/protocolVersion) and the client capabilities (io.modelcontextprotocol/clientCapabilities). - What the server implements:server/discover. - What every result carries:resultTypeand theserverInfoin_meta. - Cache hints:server/discoverandtools/listcarryttlMsandcacheScope, which isprivatein the "behind a proxy" profile, because the list depends on the area. - Errors, with the HTTP status from the specification: - a required_metafield is missing: -32602, with 400; - a version the server does not support: -32022, with 400 and, indata.supported, the same list asserver/discover(both eras), by any path; a version from the old era requested in_metaalso gives -32022, with a message saying it only works throughinitialize; - 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 usinginitialize, which answers with the requested version when it knows it. The server never issuesMcp-Session-Id, and GET and DELETE give 405. - Measured with official clients: the PythonmcpSDK 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.
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
confidenceamong the relays; the origin's strength is its method class and its ownconfidence. - 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 ontestimony(or has nomethod) counts asweak, because nobody measured it. - A broken chain (a step to a node outside the corpus) ends there, marked
found: false, and isweakin both modes. A chain that forks comes out as one path per branch; a cycle is cut and marked. --format jsongives the same projection askg-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'smeta.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 whosesourcenames itself is a cycle. - Provenance first (4.34.0): a node with a
provenance.sourcefollows only its fragment, and asourcethat 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; thesourcesays where it came from. A decision sealed by its owner thatTRACES_TOthe question it answers is the origin, not the first link of the question's history. - An origin with no
provenance.sourcecomes out withdeclared: false, and the output says that the chain may go on outside what the graph records. A chain that ends there isweakin both modes, however long, and asourceof 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-typeis never skipped in silence. - A claim relayed from another repository writes the bridge in its
provenance.sourceas the URL of that graph pinned to the commit that was read (…/blob/<sha>/<path>.kg.yaml#<id>), with atestimonymethod. The chain shows that link outside the corpus, so it is visible andweak, 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 withpinned: falseand 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: truemeans "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 isweak. Links count by file and id, andweakest_link.atgives 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 isweakin both modes. - The lenient grade leaves the origin out: a chain of strong relays from a weak origin is
stronglenient andweakstrict. A relay that rests ontestimonyis not marked down by its class, because its ownconfidencealready 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 attentionorders 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 foropen,confirmedandunverifiable, 1.3 fordrifted, 0.2 forsuperseded, 0.1 fordoneand 0 forrefuted. 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 atriggercounts 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 itsattention; the order uses the unrounded value.--decision <id>gives the history of one decision: what it replaced and what replaced it, along the wholeSUPERSEDESline, what refutes it, what supports it, and what it rests on (theSUPPORTSthat reach it, its outgoingTRACES_TOandDEPENDS_ON, and the step of itsprovenance.source; an outgoingSUPPORTSsays what the decision supports, not what it rests on), each with its status.--format jsongiveskg-ssot.decision/1(spec/decision.schema.json).--revisitlists the live decisions (notsupersedednorrefuted) that ask for a review, by attention, each with its reasons:pin-changed(since 4.40.0: a support, or the node it cites bykg-node, one step away, no longer matches the version it was pinned to, byprovenance.node_sha256or byprovenance.sha256; the same check askg_trace check, and the item carrieschanged,pin,source,recordedandcurrent),evidence-due(something it rests on is past itsreverify_every),review-due(the decision itself is past itsreverify_every) andcontested(it receives aREFUTESand is still live). A decision with onlyreverify_onwaits for its event and is listed apart; a decision with a cadence but noverified_atis listed inunstamped, and a support outside the corpus inunresolved, 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-DDsets the date; the default is today.--format jsongiveskg-ssot.revisit/2(spec/revisit.schema.json;/2since 4.40.0, for the new reason). A pin that cannot be checked goes tounresolved:pin_malformed(the hash is not 64 lowercase hex, or anode_sha256lacks thekg-nodescheme),pin_source_missing(the pinned source no longer exists) orpin_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 firespin-changed, withcurrentnull. The pins are checked only with--root, and a support read from another repository through--withdoes not have its pin checked yet, as inkg_trace check. This is the approval of a controlled document: the decision is the testimony, and an evidence thatSUPPORTSit 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.
--revisitjudges 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 asowner/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) oroutside_corpus. An unpinned support is never judged against the working tree.upstream_changedlists, 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.--decisionshows each external support with its status at the pinned commit,pinned, and, ashead_status, at the head. The head is the localHEADof the clone: rungit fetchand update it before, or the list stays empty.
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.
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 FIELDcounts, in each group, the distinct values of the field instead of the rows; repeat it to count distinct combinations (a node isto.fileplusto.id). A null value does not count, as in Cypher'scount(DISTINCT); a combination with any null does not count either, as in SQL'sCOUNT(DISTINCT a, b). The count can then be 0, andcountedsaysdistinct:<fields>.--min-count Nkeeps only the groups with a count from N, like SPARQL'sHAVING; the counts then no longer add up tototal.
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.nannever do, as with Cypher'sSTARTS WITHandCONTAINS. A date is read as the text that writes it, as in a.kg.json, sox_when^=2026-11matches. 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=blooks fora=b, andowner=^alooks 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
inand inout, and so twice intotal, 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=01anddegree.in^=1are refused (rc 2). An edge type that no internal edge of the corpus has counts 0, and thehintsays so, because the zero of a typo must not pass for "there is none". The same holds for--group-byand--count-distinct. In--revisit, whose output has nohint, such a type is refused (rc 2). Every answer with the degree says in itshintthat an edge from another graph does not count. Sodegree.in.SUPPORTS=0means no internalSUPPORTSedge in the node's own graph, and no more than that: aSUPPORTSfrom another graph is not counted, and--edges --where edge_type=SUPPORTS --where scope=externallists those to complete the answer. - Presence.
--has FIELDand--missing FIELDtake any field of the node, with a dot for a nested key, up to 10 of each. A missing field andnullare the same: they lack it. Empty text,0,false, an empty list and an empty map have a value, as with Cypher'sIS 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 withdegree, 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 ahint, because--has trcewould answer 0 and--missing trcethe whole corpus. - Which queries.
--due,--hasand--missingfilter the node queries: with--edges,--decision,--chainor--revisitthey are rc 2.--wherewith a degree or an operator also works in--revisit. - Grouping by text.
--group-byalso takeslabelandnarrative, with the whole text, andtrueand1stay 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 thenullgroup, as in any--group-by:--has narrativeleaves it out. - Group-by fields, for nodes.
file,node_type,status,plane,layer,owner,label,narrative, anx_*field,provenance.<key>,meta.<key>, anddegree.in,degree.outanddegree.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]+'
lookupcompares without case, accents, or repeated spaces. It matches the preferred term or a synonym. A synonym scoped to a case only matches with--caseset 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.termsshows when each synonym is due (verified_atplusreverify_every, on the--as-ofdate, today by default). A retired synonym is never due.export --to skoswrites Turtle in SKOS-XL with the ISO 25964 names (iso-thes). The concept getsskos:prefLabel, plusskos:altLabelandskos:hiddenLabelfor the current domain synonyms, so a plain SKOS reader sees them. Each label becomes anskosxl:Labelwith its literal form and its provenance (dc:source,kg:locator,kg:sourceVersion,kg:verifiedAt,kg:reverifyEvery). A case-scoped synonym is linked bykg:caseLabel, withkg:scopeandkg:case, and not byskosxl:altLabel, which would imply anskos:altLabelvalid for the whole concept. A scope that cannot be read counts as a case that never matches. A retired one goes asisothes:status "retired"and anskos:historyNote. A synonym that repeats a literal already said in the concept goes bykg:repeatedLabel, so the concept never has the same label twice. A superseded term getsdct:isReplacedBy. The concept IRI is<base><meta.id>#<id>, the same onekg_prov_export.pygives the node (--base,urn:kg-ssot:graph:by default); withoutmeta.id, the relative path given, or the file name, never an absolute path.export --to ossiewrites one item per live term for Apache Ossie:name(the preferred term),description,ai_context.synonyms(the current domain synonyms), and acustom_extensionsentry fromKG_SSOTwith 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).
termsgives each term its effectivescheme(the declaredx_term.scheme, or the graph) andscheme_declared.lookupalso matches anotation, exactly (a code is a code:o1is notO1), alone or qualified asscheme:notation.export --to skoswritesskos:inSchemefor each concept and each scheme once as anskos:ConceptScheme. lintchecks 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, likeA7,O1,S14, andrwith digits, liker16; it does not takeRULE 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 (ascheme:codelooks 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. Writescheme: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 .
digestprints thesha256to write inx_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.refwithout 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.refwith a fragment: the item in the canonical JCS form (RFC 8785), from a.json,.yaml, or.ymlfile. The fragment is a JSON Pointer (#/criteria/0) or theidof a list item (#CU1), unique in the file.checkchecks everyx_criterionwith exactly one ofrefandtextagainst it:integrity.criterion-digestwhen thesha256does not match;integrity.criterion-ref-unreadablewhen 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-malformedwhen the version cannot be checked: thesha256is not text (a hash of digits alone, without quotes, reads as a number), or there is not exactly one ofrefandtext, in text. Anx_measured_underthat is not a map with a list of citation maps, and a citation of another graph whosesha256is 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.
checkalso follows each<path>#<id>citation in anx_measured_under, with the same rule as the reader:integrity.criterion-unknown,integrity.criterion-sha-mismatch,integrity.measured-post-hoc, andintegrity.measured-order-unknown. - What it reads. With
--rootalone,checkreads 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 eachrefstarts, sog/a.kg.yaml#C1finds 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--excludeapplies to the files too. - Paths. A
refcannot leave the root. The last#splits the path from the fragment, and an empty fragment (a.json#) is the whole document. A.yamlfile needs a map at its root. A byte-order mark at the start of--text-fileis not part of the text. - Output and rc.
--format jsongiveskg-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.
checkchecks that each version matches its text today, not its history: editing a criterion in place and writing the newsha256passes, and only a result that cites the oldsha256shows it. Review a changedx_criterion.sha256inkg_diff(it shows as a changed field of the node): a new rule is an amendment, a new node withamends. - In CI. The reader does not hash, so a criterion edited in place without a new
sha256is caught only here: if your graphs usex_criterion, addpython3 -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_questionnairenames (nameandquestions). Who comes in: - the question: the
questionnode 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 byDEPENDS_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
SUPPORTSto an answer, or withSUPPORTSorTRACES_TOto 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
REFUTESto the answer or the question. Only a live answer brings evidence and contradictions: one that issupersededorrefuted(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 bySUPPORTSorTRACES_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 isrefutedorsupersededis listed, with its status, but does not count);contradicted: aREFUTESfrom a node that is neitherrefutednorsuperseded;no-confirmed-evidence: no evidence withstatus: confirmed(evidence that is not confirmed is listed, but does not close a gap);evidence-unstamped: confirmed evidence without a validverified_at;evidence-cadence-invalid: confirmed evidence whosereverify_everyis not a duration, so it would never be due;evidence-due: confirmed evidence whoseverified_atplusreverify_everyfalls on or before--as-of(today by default);verdict-open: no live verdict, or one other thanmeetsandnot_applicable.- Nothing drops in silence. A node whose
x_questionnairelacksnameor aquestionslist that is not empty, a node whoseroleis outside the enum, and a number inquestionsthat is not text (questions: [7]without quotes) do not come in: they come out inskipped, in the package itself (the Skipped sheet of the XLSX,--view skippedin CSV, the JSON), on standard error, and in the human listing, and--fail-on-gapgives rc 1 with them. - Your own columns.
--column <key>adds a key of the answer node (anx_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.xlsxneeds--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.yamland.kg.jsonfiles that git tracks under the root, outside the vendor,docs/materials/, and the fixture folders (fixture/,fixtures/,__fixtures__/,testdata/,__snapshots__/, at any depth).--excludeadds patterns, and--no-default-excludesdrops the defaults. A trace that is not text is counted asnot_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.locatorthat is#or starts with#/, against a.jsonfile of the repository: the path ofprovenance.sourcewhen it is a path of the repository, otherwise the path oftrace:. A source path that no longer exists isintegrity.trace-locator-missingitself: 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 asoutside_root, not judged. The fragment loses its percent-encoding, then~1becomes/and~0becomes~; 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.jsonfile 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 isintegrity.trace-locator-unreadable; both give rc 1. The JSON output gainslocators, additive and outsiderequired, so the output of 4.25 stays valid. Write the path intrace: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.sourcenames, 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 withgit 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, ismissing_source, and a branch or tag name (main,HEAD) isunpinned, since it moves. A pinned source is never checked against another version. A URL with no--withthat matches is counted asabsolute. - The hash.
provenance.sha256against 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 asgit show <commit>:<path> | sha256sum. A file with a change, or outside git, is read from disk. A difference isintegrity.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) andjson-pointer(it resolves in a.jsonsource, in both forms of RFC 6901). Out of range isintegrity.trace-locator-out-of-range; a quote that is not there,integrity.trace-quote-missing. Not judged, and counted, never as ok:pdf,mediaandhtml(form_only, judged by form in the schema), a locator outside the grammar of its scheme, a scheme outside the table or asha256out 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 inlocators; one that could not be judged here (a source that is gone, an anchor intrace:) still is, as in 4.35.0. - Output and rc. Each failure gives rc 1. The JSON output gains
schemes, additive and outsiderequired.
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.
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
.mdwhose front matter hasprojection_of, or the pages you name. A page named withoutprojection_ofis counted asno_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 declaresprojection_ofand cites no id between backticks is listed inuncited, with a warning, since nothing was checked. The contract's own words (edge types such asTRACES_TO, enum values) and the names inprojection_ignoreare counted apart. - Four buckets.
resolved(in a graph ofprojection_of);elsewhere(only in another graph of the repository: a warning, not a failure);outside(projection_ofnames a graph of another repository by URL that--withdid not read: not judged, counted);missing(integrity.projection-id-missing). --with PREFIX=FOLDERreads that other graph from a local folder, at the commit in the URL when the folder is a git repository that has it, as inkg_query --chain. A URL that--withcovers and that does not read (absent at the ref, not UTF-8, not a graph) is a graph error (rc 1), neveroutside. A graph read from the working tree because the ref of the URL is not in that folder is listed inunpinned, with a warning.- Output and rc.
--format jsongiveskg-ssot.projection/1(spec/projection.schema.json). rc 0 with nomissingand every graph ofprojection_ofread; 1 otherwise; 2 on broken input (a front matter that does not read, a--rootthat 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
scannedwithgraphs: 0and rc 0: look atscannedif you expected graphs there. Only the repository of the target and--atgive 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. Incited-by, arepo@<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 givessame, 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).fetchdownloads 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.gitignorethat 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 inembedded_now, and the nextindexredoes 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 firstindextook about 10 minutes on 8 cores, the next one 7 s, and a hybrid query with the index 7 s. Giveindexand the query the same--exclude: a node the index left out is embedded on the spot at every query. - In the MCP server,
kg_querytakessemanticwhen 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 aswasDerivedFromwould say something the graph does not say. With--edgesthey come out in a different way, described below. - The stamp as a generation time.
verified_atcomes out askg:verifiedAt(typedxsd:datewhen it is a date), not asprov: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:Edgeresource. It carrieskg:edgeType,kg:from,kg:to, the label asrdfs:label, and any other key (trigger,x_*) askg:<key>, with the key name percent-encoded so that anx_key with a space still gives a valid IRI. A value that is not text goes as JSON, typedkg:json. So any type, including the domain ones, can be rebuilt from the RDF. - An external end (
<path>#<id>) goes as the literalkg:fromReforkg:toRef, because the IRI of a node in another graph depends on that file'smeta.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 missingfrom(the whole graph) goes askg:fromGraph true. - Between two local nodes, three types also get a standard triple:
SUPPORTSgetscito:supports, from CiTO;REFUTESgetscito:refutes;SUPERSEDESgetsdcterms: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, typedkg:json. - A
kg:Graphresource at<base><meta.id>carries eachmetakey askg:<key>, and each extra top-level key (thex_*) askg: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
planwrites 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 runapply.- Each rule does one of three things: propose the
provenance, demote the node tounverifiable(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. whenmatches 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 theSUPPORTSedges that reach the node, withnotandany_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'snotegoes at the end of everymethoddetail, for examplenão reverificado na migração de 2026-10-20(the note in the example profile). scopesgivesverified_scopeonly 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
testimonyon a PROD node raisesintegrity.testimony-in-prod: the proposal goes ahead, and the node goes to the reviewer with the warning named. applywrites 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.yamlfiles fromgit ls-files. It leaves out thekg_gate.pypatterns (*/fixtures/*anddocs/materials/*),vendor/*, any folder with the.kg-ssot-versionstamp (since 4.20.0), and the--excludeglobs, with the gate's semantics:fnmatchon the relative path, where*crosses/; the profile dialect's**/, which matches zero folders, also works.--no-default-excludesremoves the patterns. Note: in the profile'strace_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_scopealready matches the profile comes out assame, separate from the nodes that change, andapplydoes 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.