Decided 11 Sep 2026 (KG‑1…7). Built 13 Sep 2026
(slice 61): domain/kg/, migration 0027-kg, the
knowledge-graph plugin, search, the kg_query
agent tool, /api/kg/*, the Knowledge tab. See As
built at the end. Deliberately the narrowest thing that is
still a knowledge graph: a v1 to build, ship, and then find out what
people actually ask of it.
A knowledge graph here is entities and the relations between them, extracted from the team's own documents, with every fact traceable to the document and the version it came from. It answers "what do we know about X, and where did we learn it" — across a repository nobody can read end to end.
It is not a general-purpose graph database, a reasoning engine, an ontology project, or a place where facts live that did not come from a document. Nothing enters the graph without a source. That single rule is what makes the rest of this design fall out: authorization is inherited from the source, staleness is inherited from the source, and deleting the source deletes the fact.
Everything the graph needs already exists. This is the same argument the Localization Manager just made good on:
| Need | Already have |
|---|---|
| The documents | the repository (any format, creator-set visibility, versioned) |
| Their text | repo_text, produced by the
index-documents workflow (slice 54) |
| A way to run extraction over a corpus without flattening the model | the workflow engine: find-unprocessed →
map fan-out → an LLM tool, quota-admitted |
| Who may see a fact | can? on the source object — no new
authorization code, just as the repository needed none |
| Meter the AI spend | the quota ledger, ai.tokens.total, per
team |
| Surfaces | search (search-all), an agent
tool, a console tab |
The new parts are two tables, one extraction tool, one workflow spec, one query module and one tab.
Three concepts. Entities are things with a type and a canonical name. Relations are typed, directed edges between two entities. Mentions bind either to the document and version that asserted it, with the sentence that did.
kg_entities(id pk, team_id, type, name, name_norm, description, first_seen_at,
uniq(team_id, type, name_norm))
kg_relations(id pk, team_id, subject_id fk, predicate, object_id fk, first_seen_at,
uniq(team_id, subject_id, predicate, object_id))
kg_mentions(id pk, team_id, entity_id fk | relation_id fk, -- exactly one
object_id, version_id, snippet, extracted_at)
(type, name_norm):
"Acme Robotics", "ACME robotics" and "Acme Robotics Inc" are the same
organization once normalized (case, whitespace, a small
suffix list). Cross-type merging ("Acme" the org vs "Acme" the product)
is deliberately NOT attempted in v1; it is where knowledge graphs go to
die.person, organization,
product, place, event,
concept — and a plugin may add to it. A closed set makes
the extractor refuse things; an unbounded one makes the graph
unqueryable. Open-with-starter is the compromise, revisited under
KG‑1.works_at, part_of, depends_on),
from the extractor's own choice, lower-snake-cased. The starter prompt
suggests a dozen; it does not enforce them.A workflow, index-knowledge, in the same plugin family
as index-documents:
kg_list_unextracted (≤ 40 objects/run, team-scoped)
└─ map ──▶ kg_extract (LLM tool: repo_text → {entities, relations}, with snippets)
└─ upsert entities/relations/mentions for (object_id, version_id)
define-tool that
asks the model for a strict JSON shape and refuses anything that
does not validate — a mention whose snippet is not a substring
of the source text, an entity with an empty name, a relation to an
entity not in the same reply. The Manager's lesson applies unchanged: a
bad fact that looks finished is worse than a missing one.(object_id, version_id); extracting version
n+1 removes version n's mentions for that object, and
entities left with no mentions are pruned. The graph never says
something the current documents no longer say.index-documents rule, kept.A fact is visible to a principal iff at least one of its
mentions is on an object that principal can? read.
There are no permissions on entities. There is no "graph visibility" to
configure. There is nothing to get wrong.
The consequence is deliberate and should be stated plainly: a fact that appears in one private document and one team document is visible to the team, and its snippet from the private document is not. Mentions are filtered per-row exactly as search results are. The alternative — all-sources-must-be-visible — hides a public fact because a private note also states it, which is both surprising and unhelpful. KG‑3.
Three, in order of how much they will actually be used:
search-all gains a fourth
result kind, entity, hit by name; the row filter is the
mention rule above. Zero new UI: entities appear beside notes and
objects.kg_query(name | id, hops ≤ 2) returns the neighbourhood as
text with citations. This is the one that turns "what do we know about
Acme" into an answer with sources, and it is where the graph earns its
keep.The HTTP surface is small and mirrors the tab:
GET /api/kg/entities?q=&type=,
GET /api/kg/entities/<id> (relations + mentions,
filtered), and POST /api/kg/extract to queue the workflow
(workflows:run).
No graph query language. Not Cypher, not Gremlin, not SPARQL — no
dependency, no parser, no injection surface, and no user has asked for
one. hops ≤ 2 from a named entity covers the questions
people have. KG‑5.
The three tables above, plus nothing. repo_text is read,
never written. Jobs, quota and audit are the existing tables. The
extractor's output contract:
{ "entities": [{"type":"organization","name":"Acme Robotics","description":"…",
"snippet":"…verbatim from the source…"}],
"relations": [{"subject":"Acme Robotics","predicate":"customer_of",
"object":"Globex Media","snippet":"…verbatim…"}] }
snippet must be a substring of the text the tool was
given. That is the validation, and it is also the provenance.
KnowledgeGraph:
extract(object_id, version_id) -> {entities, relations} # the tool; validated, refuses on bad shape
upsert(team, object_id, version_id, extraction) # supersedes prior version's mentions
forget(object_id) # on delete; prunes orphans
entity(principal, id) -> {entity, relations, mentions} # mention rule applied per row
find(principal, q, type?) -> [entity…]
neighbourhood(principal, id, hops) -> subgraph # for the agent tool
domain/kg/kg.rkt with
upsert/forget/entity/find
and the mention-rule filter; unit tests that prove the rule
(private-vs-team mentions).kg_extract tool with strict validation; tested
against a scripted model and the deterministic seam.index-knowledge workflow (plugin
knowledge-graph).kg_extractions(object_id, version_id, entities, relations).
"Three tables plus nothing" did not account for the document with no
entities in it, which would otherwise be listed as unextracted forever.
It is the same role repo_text plays for indexing: a
per-version marker.kg-entity
returns #f both when an entity does not exist and when the
caller can see no mention of it; the two are indistinguishable on
purpose.current-doc-chat), so one
scripted reply drives every test.(team, type, normalized name)
with a small suffix list (inc, ltd, gmbh, …); the first spelling seen is
the display name; the longest description seen is kept.domain/ai/roles.rkt): kg_extract
routes its model call to the team's utility executor when
one is set (PUT /api/model-roles, else
TELEMACHUS_MODEL_UTILITY, else the local model) — typically
a pull executor on a cheaper or better-placed host. The pipeline's
extraction and translation and the Localization Manager's drafts share
the role; a person's chat never uses it.| # | Decision | Recommendation · alternatives | Why it matters |
|---|---|---|---|
| KG‑1 | Entity types | Open vocabulary with a shipped starter set, plugins may extend · vs a closed schema · vs fully open | Closed refuses real things; unbounded is unqueryable. |
| KG‑2 | Scope | Team-scoped, org gate at step 0 · vs instance-wide | A company's knowledge is not the product's. |
| KG‑3 | Visibility of a fact | Visible if ANY source mention is readable; snippets filtered per row · vs all sources must be readable | All-sources hides public facts because a private note repeats them. |
| KG‑4 | Dedup | (type, name_norm) only; no cross-type merging
in v1 · vs embeddings-based entity resolution |
Cross-type merging is where knowledge graphs die; earn it with data. |
| KG‑5 | Query language | None; hops ≤ 2 from a named entity ·
vs Cypher/Gremlin/SPARQL |
No dependency, no parser, no injection surface, no demand. |
| KG‑6 | Visualization | Lists with citations first; hand-drawn SVG neighbourhood later if used · vs a graph-viz library now | A force-directed canvas is a dependency and a week of tuning before anyone has asked a question. |
| KG‑7 | Extraction model | The utility role,
translation-style opt-in per team · vs always the
chat model |
Extraction is bulk and cheap-model-shaped; the Manager's LOC‑5 precedent. |