OKF bundles
A namespace can be rendered as an OKF (Open Knowledge Format) v0.2 bundle: one Markdown
file per memory with YAML frontmatter, an index.md per directory listing each concept
with a one-line description, and a log.md of changes. The CLI is client-side, over the
HTTP API. A bundle can be diffed, reviewed in a pull request and read offline; Loom stays
the store.
The bundle is a projection, not a second store: it is regenerated from the namespace, and an edit comes back through the ordinary write path (conflict detection, blacklist, audit).
loom export-okf --namespace <ns> --out ./knowledge # render
loom okf-lint ./knowledge # offline check: exit 2 critical, 1 warnings, 0 clean
loom import-okf ./knowledge --namespace <ns> --dry-run # plan the write-back
All three take --server-url, --token (default $LOOM_API_KEY, then
~/.loom/token) and --org (default: the token's org claim); okf-lint needs none of
them. Every flag is in the CLI reference.
Export
loom export-okf is read-only. It pages every live row of the namespace (all agents)
through memory.list, fetches full values with memory.bulk_get, and writes the planned
files under --out. Existing concept files are overwritten; nothing else in the directory
is touched. The mapping lives in server/memory/domain/okf_bundle.py (pure functions, unit
tested); server/cli/okf.py is the transport and the file system.
| Memory | Concept |
|---|---|
memory_key |
file name and loom.memory_key: the key's slug, capped at 160 bytes, with a -<hash> of the key and row id when another key holds that name or it is a bundle file's (index, log, README, AGENTS, in any case) or a Windows device name (nul, con, com1) |
memory_type |
directory (the type's lowercased slug, capped like a file name; type-<slug> for evidence, digests, staging, node_modules, a bundle file's full name (index.md, log.md) or a Windows device name such as nul) and type: semantic → Fact, procedural → Procedure, preference → Preference, episodic → Event, anything else title-cased |
| value | body: text as is, JSON as a fenced block |
tags |
tags |
agent and created_at |
generated: {by: "agent:<agent>", at} |
live verified_by / verified_at |
verified: [{by, at}] (human and process attestations only) |
status |
active → stable, conflicted → draft, anything else → deprecated |
expires_at |
stale_after, a review-by date |
source_resource_id |
sources: [{resource}] |
| everything Loom-specific | the loom: block, including source_class, sensitivity, superseded_by, valid_to and content_sha256 |
Links. Edges someone authored (the namespace's typed memory.link edges, read with
memory.export_evidence kind=edges, and the supersedes chain a superseded row implies)
are written as authoritative frontmatter relates_to: [{path, kind}] and mirrored in the
body's "Related Concepts" section. Shared-entity links follow, capped and symmetric, so
the bundle has a graph a validator can walk. Concepts that link to nothing remain orphans,
and the CLI reports them rather than hiding them.
Provenance travels. The loom: block carries the row's provenance tier so a round
trip cannot turn an inference into a first-hand fact.
| Flag | Effect |
|---|---|
--include-high |
also export sensitivity: high rows, the raw chunks and digests linked to them, and raw text that holds a high shape (a credential, card number, SSN or stated PIN); the server audits each released item; memory.bulk_get does not serve a high row past its retention window, so it is named and left out. Off by default: a mirror repository is a wider audience than the namespace's grants |
--no-evidence |
concepts only; skip the raw chunks (evidence/) and session digests (digests/) the bundle carries by default |
--no-links |
skip the typed edges (shared-entity links are still written) |
--include-tombstoned |
also export soft-deleted rows, as status: deprecated; one the list does not carry in full (a value past the preview, or a high row under --include-high) is named and left out |
--limit N |
at most N most-recent rows (smoke runs) |
--title, --dry-run |
the root index title; plan without writing |
Lint
loom okf-lint DIR reads the bundle directory and nothing else: no server, no ClickHouse,
no network. Each finding carries an id, a path, a message and a severity
(server/memory/domain/okf_lint.py).
| id | Severity | Means |
|---|---|---|
frontmatter |
critical | a concept has no frontmatter, unparseable YAML, an empty type or a tags value that is not a list |
required_keys |
critical | title, description, loom.memory_key or loom.memory_type is missing |
relates_to |
critical | an edge is malformed or its .md target does not exist in the bundle |
index_missing / okf_version |
critical | a directory with concepts has no index.md; the root index declares no okf_version |
staging |
critical | a staging/ directory exists; staging never answers |
type_directory_mismatch |
warning | loom.memory_type disagrees with the folder the file sits in |
pointer_unmounted |
warning | a relates_to path is a loom://org/namespace/key pointer to a concept outside the bundle |
unknown_relation |
warning | a relates_to kind outside Loom's canonical verbs |
edited_since_export |
warning | the body no longer hashes to loom.content_sha256 (a concept or raw chunk edited by hand) |
derived_needs_confidence |
warning | source_class: derived at confidence 1.0: an inference written as a fact |
high_sensitivity_present |
warning | a high row (loom.sensitivity: high or the sensitivity:high tag) travelled |
index_listing |
warning | a concept its directory index or the root index does not list |
drift |
warning | the root index's loom.corpus_digest no longer matches the concept files |
orphans |
info | concepts that link to nothing and are linked from nothing (a property of the data, not a defect) |
Exit codes follow what a compile step needs: 2 on any critical finding, 1 on
warnings only, 0 clean. --strict makes warnings exit 2 as well (orphans stay
informational); --json prints the machine-readable report.
The corpus digest. When the root index.md carries loom.corpus_digest, the lint
recomputes it: take the concept files only, sort them by bundle-relative path, append each
file's SHA-256 hex digest with no separator, and SHA-256 that string. A mismatch is the
drift warning, naming how many concept files the tree holds against what the index says.
Import
loom import-okf DIR --namespace NS lints first and refuses a bundle with critical
findings unless --force-import (--no-lint skips the gate). It then writes each concept
through memory.set, with identity from the token or the --agent, --user-id and
--session-id flags, subject to conflict detection, the blacklist and the audit log.
Afterwards it re-creates relates_to edges with memory.link (--no-links skips this).
Before writing, the import reads the live rows under its identity and skips every concept
whose row already holds what the write would send: the value (content_sha256), type,
tags, confidence, valid_at, valid_to, source_trace and source_class (the declared
class, else imported), and the entities when the bundle carries them. It skips edges
that are already live. Concepts that land on the same row (one key exported for several
writers) are written once: the most recently updated wins. Re-sending an identical row
would count as a reinforcement, so importing the same bundle twice writes no concept or
edge the second time, except a concept with sources:, which is written on every run
because the reads the import makes do not return its source, and a concept the target
merges into another key, which is sent again on every run and reinforces that row (counted
as merged). The last line counts the target rows written, unchanged, merged and failed.
source_trace is okf:<path inside the bundle>, so where the bundle sits on disk does
not change it: each run can export to a fresh directory.
The bundle's evidence/ and digests/ are written back through memory.import_evidence
(--no-evidence skips them) and re-embedded by the importing server on every import,
changed or not: a scheduled import passes --no-evidence.
A chunk or digest whose text reads as high (a credential, card number, SSN or stated
PIN), or a chunk that holds part of one cut at a chunk boundary, is written without a
vector and is not searchable
(sensitivity).
| Flag | Effect |
|---|---|
--verify |
re-attest concepts whose frontmatter carries a human: verification, through memory.verify, as the importing caller |
--keep-sessions |
write each concept under the session in its loom.session_id, so a key that lived under several sessions stays several rows; --session-id is the fallback |
--dry-run |
plan the writes against the target's live rows (it reads them) without sending any write |
--prune |
delete the rows an earlier import with this --agent and token wrote (an okf: source_trace) whose key is no longer in the bundle, through memory.delete_version; needs --agent |
Each bundle imported into a namespace with --prune needs an --agent of its own: prune
deletes the rows of that agent with an okf: trace, written by its token, that the bundle
lacks. --prune refuses a bundle with no concepts and one whose concept files do not hash
to the root index's loom.corpus_digest, so a hand-edited bundle never drives deletes. A
bundle whose root index does not carry loom.complete: true is written but not pruned.
Export writes the marker only when it read every row: every row memory.keys reads in
one statement after the listing was listed, and every listed row came back from
memory.bulk_get. Never when it stopped at --limit, with --include-tombstoned (which
keeps deleted rows in the bundle), or from an older release. Each delete goes through
memory.delete_version with the version the import's listing saw with an okf: trace,
under that exact identity: a row rewritten since, or locked with memory.lock, is kept,
and a target too old to know the op deletes nothing. Export into an empty directory for a
prune: export overwrites concept files but never removes the file of a key that left the
namespace, so a reused --out directory no longer matches its digest. Copying a
namespace to another Loom runs export and import on a
schedule.
--verify never forges an attestation. The server decides the actor from the token; the
bundle's original verifier is recorded in the memory's tags for provenance.
CI gate
integrations/okf-ci/okf-lint.yml is a GitHub Actions workflow for the repository that
holds the bundle:
- Copy it into that repository as
.github/workflows/okf-lint.yml. - Set
LOOM_REFto a 40-character Loom commit SHA. The job refuses any other value (the file ships with it empty): the rules are versioned with Loom, and a branch or tag can move under a green check. - Set
OKF_ROOTto the bundle directory, the one holding the rootindex.md(default.): one namespace's bundle, or a matrix over the namespaces the repository holds.
The job checks out Loom at LOOM_REF beside the bundle and runs the lint with
uv run --frozen, so the packages come from that commit's uv.lock: one LOOM_REF is
one set of rules and one set of dependencies. It runs the lint with --json, prints the
summary and the first forty findings (sorted by severity, id and path) into the job log,
uploads okf-lint.json as an artifact, and fails on exit 2 or on a lint that did not run
(any other exit code). Warnings pass, with the report kept for the reviewer.
Every action is pinned to a full commit SHA, with its version in a comment. The workflow
asks for contents: read only, and neither checkout keeps the token in the git config
(persist-credentials: false). The lint itself opens no network connection and needs no
ClickHouse. integrations/okf-ci/README.md describes each check.
Run the gate locally
The same command gives the same result:
git clone https://github.com/ClickHouse/loom .okf-lint-loom
git -C .okf-lint-loom checkout <LOOM_REF>
uv run --project .okf-lint-loom --frozen --python 3.12 loom okf-lint <OKF_ROOT> # human report
uv run --project .okf-lint-loom --frozen --python 3.12 loom okf-lint <OKF_ROOT> --json # the CI report
Limits
- A bundle is a snapshot. Changes made in Loom after the export are not in it until the next export; re-export before editing a stale bundle.
- Deletions travel only with
--prune, and only for rows the import wrote. Edges removed from the bundle are not deleted. - High-sensitivity rows stay home unless you pass
--include-high: a row is high by itssensitivityfield or itssensitivity:hightag. A row whose sensitivity the export could not read, or read as a level it does not know, stays home too. So do the raw chunks and session digests linked to such a row, and any whose text holds a high shape (a credential, card number, SSN or stated PIN; raw text has no sensitivity column, so the detectormemory.setruns decides, whateverLOOM_MEMORY_SENSITIVITY_AUTODETECTsays). The lint warns when a high row travelled.
Related
- Writing memory and conflicts and refusal: what an import goes through.
- History and provenance.
- Entity graph: the typed edges
relates_tocarries.