From a Neo4j Graph Back to an OKF Bundle

William Lyon

August 9, 2026

18 min read

Neo4jOKFKnowledge GraphCypherPythonAgentsGoogle Cloud

The first post in this series moved in one direction. It parsed Google's Open Knowledge Format (OKF) bundles into a Neo4j property graph. Then it used the graph to govern what an LLM sees at retrieval time.

That post left one question open. If your organizational knowledge now lives in a graph, is it locked in the graph?

The answer is no, and the answer is the subject of this post. I added the return direction to neo4j-okf in pull request #1. The new command is okf-graph project. It reads a selected part of the graph. It writes a complete OKF bundle: the same markdown, the same frontmatter, readable by any OKF consumer.

A projection is not an export. An export copies what is already on disk. A projection runs a selection query first, and serializes the result afterwards. The bundle that comes out never existed on disk. That difference is the whole point, and the rest of this post is about what it buys you.

Four operations connect OKF files, an in-memory model, and Neo4j. Parse and ingest go from files to the graph. Project and emit go from the graph back to files.

What you'll learn: how to turn governance rules into a portable bundle, why a filter must never break the bundle it filters, and how to make a round trip a tested property instead of a hope. Who this is for: developers who work with Neo4j, RAG pipelines, or agent context. You do not need to read the first post first. You do need to be comfortable with Python and to have seen Cypher once.

The Model Is the Center, Not the Graph

One design decision shapes everything else. The center of the system is not Neo4j. The center is a plain in-memory model called ParsedBundle.

Four operations connect to that model:

operationdirectionwhat it does
parsefiles → modelreads an OKF bundle from disk
ingestmodel → Neo4jwrites the model to the graph
projectNeo4j → modelreads a selected part of the graph back
emitmodel → filesserializes the model as OKF markdown

The first post used parse and ingest. This post uses project and emit.

Each operation is one mapping. Nobody writes an OKF serializer that can drift from the OKF parser, because there is only one of each. A concept that goes in as a :Metric node comes back as a Metric file, because the same model describes both. This matters more than it sounds. Two-way integrations usually fail at the second direction, and they fail because somebody wrote the reverse mapping separately and then let it drift. One pair still has to agree by hand: ingest writes the graph properties and project reads them back. Test 2 below fails the moment one side forgets a field.

A Projection Is a Selection Query

The project command takes a selection. You can select by identity, by neighborhood, or by governance state.

flagwhat it selects
--bundlewhich bundle in the graph
--concept IDonly these concepts (repeatable)
--seed ID --hops Neverything within N dependency hops of a concept
--tag, --type, --statusfrontmatter filters (repeatable)
--min-trustunverified, machine-confirmed, or human-reviewed (§5.3)
--exclude-staledrop concepts where today >= stale_after (§5.5)
--include-referencedadd one hop of link targets so links resolve - the targets bypass the trust, status and staleness filters
--format dir|tar|zipa directory, a .tar.gz, or a .zip
--dry-runprint the manifest and the file list, write nothing

Use that last flag with care. It adds link targets whatever their trust tier or status. On this bundle it puts metrics/gross-margin-legacy straight back in, and the manifest then reports no cut links at all.

Each flag becomes a predicate in one Cypher query. Here is the core of it, from okf_graph/project.py:

MATCH (c:Concept {bundle: $bundle})
WHERE NOT coalesce(c.stub, false)
  AND ($ids IS NULL      OR c.id IN $ids)
  AND ($types IS NULL    OR c.type IN $types)
  AND ($statuses IS NULL OR c.status IN $statuses)
  AND ($tags IS NULL     OR any(t IN coalesce(c.tags, []) WHERE t IN $tags))
  AND ($rank IS NULL OR
       (CASE c.trust_tier WHEN 'human-reviewed'    THEN 2
                          WHEN 'machine-confirmed' THEN 1
                          ELSE 0 END) >= $rank)
RETURN collect(DISTINCT c.uid) AS uids

Note the first predicate. Stub concepts are never selected. In OKF, a stub is a link to a document nobody has written yet (§6.1). The graph keeps stubs as nodes, because an authoring backlog is useful. A projection must not write them as files. That would invent knowledge that no author ever produced.

Run It

The sample bundle holds nine concepts. Ingest it first, exactly as in the first post:

git clone https://github.com/johnymontana/neo4j-okf && cd neo4j-okf
docker compose up -d
uv sync
uv run okf-graph ingest bundles/acme_retail --reset

Now ask a governance question, and take the answer as a bundle. "Give me the knowledge we can serve today: human-reviewed, stable, and not stale."

uv run okf-graph project /tmp/servable --bundle acme_retail --name acme_servable \
    --min-trust human-reviewed --status stable --exclude-stale
projected 'acme_retail' -> 'acme_servable': 8 concepts, 18 files
  dangling link: metrics/gross-margin (§ Definition) -> metrics/gross-margin-legacy  [excluded by projection filter]
  dangling link: metrics/gross-margin (§ What changed in FY2026) -> metrics/gross-margin-legacy  [excluded by projection filter]
  dangling link: policies/margin-standard (§ Cited by) -> metrics/gross-margin-legacy  [excluded by projection filter]
wrote /tmp/servable

Nine concepts went in. Eight came out. The concept missing from the output is metrics/gross-margin-legacy - the deprecated definition of gross margin.

Readers of the first post will recognize it. That file is the landmine. It is the well-written, retired formula that vector search retrieves and that an LLM then repeats. In the first post, the graph annotated it at retrieval time. Here the graph removes it at packaging time, and the result is a bundle you can hand to a system that has never heard of Neo4j.

One caveat, and it is a real one. Artifacts are not filtered. The projection writes every file the bundle owns, because a file like viz.html belongs to no concept. That viewer embeds every concept body, so the filtered directory still carries the text the filter just removed. Pass --no-artifacts when the filter is the point.

Nine concepts in the graph pass through three governance filters. Eight are written to the projected bundle. The deprecated legacy metric is dropped, and the unverified skill is kept.

The Consumer Contract Is Always Closed Over

Look at the output again. One concept survived a filter that should have removed it.

skills/run-on-bq has no verified: entry, so its trust tier is unverified. The filter asked for human-reviewed. The skill is still in the bundle.

This is deliberate, and it is the most interesting behavior in the whole module. Two Attested Computations in the bundle name that skill as their executor:

# computations/gross-margin-period.md
executor:
  resource: skills/run-on-bq.md
  receipt: [job_id, executed_sql, result]

An Attested Computation is the sanctioned way to produce a number (OKF §10). An agent may bind the parameters. An agent may not edit the SQL. The executor names the skill that runs it, and the attester names the code that checks the result.

Now consider what a strict filter would produce. It keeps the computation, because the computation is human-reviewed. It removes the skill, because the skill is unverified. The bundle then ships sanctioned SQL with no way to run it. It advertises a consumer contract that nobody can follow.

So the projection closes over three relationship types before it writes anything, whatever the filter says:

MATCH (c:Concept)-[:EXECUTED_BY|ATTESTED_BY|COMPUTATION_FILE]->(t)
WHERE c.uid IN $uids AND t:Concept AND t.bundle = $bundle
  AND NOT coalesce(t.stub, false) AND NOT t.uid IN $uids
RETURN collect(DISTINCT t.uid) AS uids

The trust filter on its own keeps a computation and drops its executor, which leaves sanctioned SQL nobody can run. The projection pulls executor, attester and computation targets back in.

I want to be precise about what this rule is. It is not a convenience. It is the recognition that a filter operates on a graph of obligations, not on a list of rows. Governance that breaks the bundle is not governance. If your trust policy can produce a bundle that lies about its own capabilities, the policy needs the fix, not the bundle.

The same argument applies to the artifact files. attesters/sql_equality.py is the code that decides whether a computed number is trustworthy. The graph stores its contents, not only its path, so the projection writes a real file. A bundle whose attester is a dangling path is a bundle you cannot verify.

Context Packs

The second use of the selection is smaller and more practical. Take one concept, walk out a fixed number of hops, and write the result as one archive.

uv run okf-graph project /tmp/gm_context.tar.gz --bundle acme_retail \
    --name gm_context --seed metrics/gross-margin --hops 1 --format tar
projected 'acme_retail' -> 'gm_context': 7 concepts, 16 files
  dangling link: computations/gross-margin-period (§ Notes on the COGS composition) -> computations/revenue-ytd  [excluded by projection filter]
  dangling link: metrics/revenue (§ Definition) -> computations/revenue-ytd  [excluded by projection filter]
  dangling link: policies/revenue-recognition (§ Cited by) -> computations/revenue-ytd  [excluded by projection filter]
  dangling link: policies/revenue-recognition (§ Cited by) -> tables/orders  [excluded by projection filter]
wrote /tmp/gm_context.tar.gz

This selection cuts links too, and the pack says so. That is the same audit trail, on a filter that has nothing to do with trust.

The result is 19 KB. It holds seven concepts, a regenerated index.md in each directory, the log.md, the attester source code, the bundle's viz.html, and the manifest. It also holds metrics/gross-margin-legacy.md. The walk follows LINKS_TO, and metrics/gross-margin links to the definition it replaced. A hop radius selects by distance, not by trust. Add --status stable if you want both filters - that gives six concepts and 15 files, and leaves the landmine out.

The neighborhood walk uses almost the same relationship types as the impact analysis query from the first post:

NEIGHBOUR_RELS = "LINKS_TO|EXECUTED_BY|DERIVES_FROM|RESOLVES_TO|ATTESTED_BY|COMPUTATION_FILE"

It adds ATTESTED_BY and COMPUTATION_FILE, because a context pack has to carry the attester and the computation file. It leaves out HAS_SECTION and MENTIONS, which the impact query needs only to reach a concept through a section. The seed walk is also undirected, where the impact query follows the arrows. The dependency edges themselves are the same in both, and that is on purpose. "What is affected if this changes?" and "what does an agent need to reason about this?" are the same question asked in two directions.

I find this the most useful shape in the module. "Assemble the context an agent needs for gross margin, one hop out, as a tarball" is one command. The output is a portable file. You can attach it to a task, commit it to a repository, or hand it to a model that has no database connection. The graph did the selection. The format did the transport.

Nothing Is Dropped Quietly

A filter removes things. A bundle that hides what a filter removed is dishonest.

Every projection therefore writes an audit trail to .okf/projection.json:

{
  "spec":   { "bundle": "acme_retail", "min_trust": "human-reviewed",
              "statuses": ["stable"], "exclude_stale": true },
  "stats":  { "selected": 8, "concepts": 8, "sections": 19, "links": 14 },
  "dangling_links": [
    { "from": "metrics/gross-margin", "section": "Definition",
      "to": "metrics/gross-margin-legacy",
      "reason": "excluded by projection filter" }
  ],
  "unmaterialized_artifacts": [],
  "roundtrip_notes": [ "index.md is regenerated from the graph (SPEC §8 …)", "…" ]
}

The links that point at excluded concepts stay in the markdown. They are not rewritten and they are not deleted. A broken cross-link is legal OKF, because §6.1 requires consumers to tolerate one. It is also the honest record of what the filter cut. The manifest names every one, with the section it appeared in and the reason it dangles.

Two more fields deserve a note. unmaterialized_artifacts lists files the graph knows only by path and hash. The graph carries file contents under 64 KB. A larger file, or a file that is not UTF-8, is recorded but not written. The projection says so rather than letting the file disappear. roundtrip_notes holds the ten places where a re-emitted bundle differs in text from a hand-written one, which is the subject of the next section.

One field I left out of the sample above. The manifest also carries a source block, which records NEO4J_URI and the database name. That is useful inside your network. It is an internal hostname in a file you may hand to somebody else, so strip it first. project only reads, so run it as a read-only Neo4j user - the same client class can also delete a bundle.

One asymmetry, since I am claiming honesty. The manifest records what the filter cut. It does not record what the consumer contract added back. The acme_servable manifest says min_trust: human-reviewed, the bundle ships an unverified skill, and only that skill's own frontmatter tells you so. An added_by_contract list belongs in the manifest, and it is on my list.

The projected tarball holds markdown, a regenerated index, the log, the carried attester code, and a manifest that records the selection, the cut links, and the round-trip notes.

The manifest sits under a dot-directory for a reason. The parser skips dotted paths. The audit trail can therefore never be read back in as bundle content.

One warning about the output. project writes files. It does not delete them. If you project into a directory that already holds an older projection, a concept the filter removed this time is still there from last time. The new manifest says the link to it was cut, and the file is still on disk. Project into an empty directory, or use --format tar and replace the archive.

Round-Trip Fidelity Is a Tested Property

Here is the claim that makes the whole design safe to rely on: the graph adds selection, and adds nothing else.

That claim is testable, so it is tested. Three assertions, condensed here from tests/test_roundtrip.py and tests/test_project.py:

# 1. files -> model -> files -> model returns an equivalent model
assert fingerprint(reparsed) == fingerprint(original)

# 2. going through Neo4j is byte-identical to serializing the parse directly
via_graph = project(writer, ProjectionSpec(bundle=BUNDLE)).files
via_graph.pop(".okf/projection.json")          # the audit trail, not content
assert via_graph == render_bundle(parse_bundle(BUNDLE_DIR, BUNDLE))

# 3. emit is a fixed point from the second pass
assert render_bundle(parse_bundle(once)) == once

Run them yourself. With a database up, the suite is 74 tests and takes about a second:

$ uv run pytest -q
..........................................................................
74 passed in 1.07s

Without a database, the eight projection tests skip themselves and the other 66 still run. Assertions 1 and 3 are in that offline group, because no graph is needed to check that parse and emit are inverses. Assertion 2 needs the database, which is the whole point of it.

Three tested round-trip claims: parse then emit then parse returns an equivalent model, going through the graph is byte-identical to a direct emit, and emit is a fixed point from the second pass.

The promise is equivalence, not byte equality with the author's original file. I think the distinction is worth stating plainly, because "lossless" is a word people use loosely. A re-emitted bundle differs from a hand-written one in ten enumerated ways. Some examples:

  • index.md is regenerated from the graph. OKF says it is derivable (§8), so the ingester never reads it, and a hand-written directory blurb does not survive.
  • status is always written, even when it holds the default value stable.
  • Datetimes get an explicit offset. Z becomes +00:00.
  • Unknown top-level frontmatter keys survive, but they move below the known families.

And two known limits, stated rather than hidden:

  • An unknown key nested inside a known family is lost. generated.model and sources[].license are the examples.
  • An artifact over 64 KB, or one that is not UTF-8, is recorded by hash and path rather than written.

All ten notes live in emit.ROUNDTRIP_NOTES, and every manifest carries a copy. A consumer can audit the difference instead of discovering it in a diff.

The Filesystem Is a Hostile Channel

One more piece, because it took me longer to get right than the Cypher did.

Writing files is the dangerous part of this module. A concept path in the graph is not always something I typed. It can come from an LLM-authored bundle, which in turn came from a fetched web page. By the time it reaches a file write, it is untrusted input.

So emit validates every path before anything touches a disk:

  • Traversal is refused. ../escape.md and /etc/passwd never resolve.
  • Windows device names are refused. A concept called nul cannot be written.
  • Two entries that claim one path raise EmitCollision. The second one does not overwrite the first.
  • A concept whose id is index or log collides with the generated reserved files, and is therefore an error rather than a silent replacement.

The validation lives in the renderer, not only in the directory writer. The tarball writer and the zip writer get the same guarantee, and so does any caller that takes the returned dict and writes it somewhere else.

Both archive writers fix modification times, fix permissions, and sort entries. Two things still move. The gzip container stamps the current time into its own header, so two .tar.gz files of one projection differ - the .zip does not. And every manifest records projected_at, so the content differs too. If you want to hash an archive or check one into git, use --format zip and pin or strip projected_at first.

What's Next?

The recipe, in short:

  1. Ingest your OKF bundle into Neo4j, as in the first post.
  2. Write the governance rule as a selection: trust tier, status, staleness, tags, or a seed and a hop radius.
  3. Project it. The consumer contract closes automatically, so the bundle stays runnable.
  4. Read the manifest. It names every cut link and every file that could not be written. project exits 0 whatever happens, so on a schedule you have to gate on the manifest yourself. Misspell the bundle name and the command writes an empty bundle and reports success. Read .okf/projection.json and fail the job when stats.selected is zero.
  5. Ship the directory or the archive. Project into an empty directory, or ship the archive. Any OKF consumer can read it, with or without a graph.

There is a third direction I did not cover here, and it is the subject of the next post in this series. The same pull request adds okf-graph wiki, which takes ordinary documents - intranet HTML, a finance memo, a README, a runbook - and produces an OKF bundle from them. The generated concepts declare status: draft, they carry no verified: entry, and their trust tier is therefore unverified. They land in the same graph as Finance's human-reviewed definitions and they cannot outrank them. That is the argument for putting trust in the format rather than in the retrieval code, and it deserves its own post.

The loop that interests me most closes right there: documents become a draft bundle, the bundle becomes a graph, a human reviews a concept and adds a verified: entry, and the next projection includes it because it finally passes the trust filter. Agents author. Humans verify. The graph tracks both. I will write that one up next, and the newsletter below is where it will land first.

Resources

The sample acme_retail bundle and the OKF specification are from GoogleCloudPlatform/knowledge-catalog, Apache License 2.0. neo4j-okf is a community demo and is not affiliated with Google.

Stay Updated

Get notified about new posts and videos

Recommended for You


NewsletterBlogRSS

© 2026 William Lyon. Built with Next.js and Chakra UI.