OKF edge vocabulary is registry-driven — a vertical declares its own traversable edges

0033-registry-driven-edge-vocabulary

decision read as Explain confidence verified status active 2026-06-16 owner mcp-engineer
Reversibility
two-way door

DEC-0033 — Registry-driven edge vocabulary (verticals extend the graph, never fork it)

Context

The seam was found serving a real external client KB through GraphRAG (the RBA serve-proof — see First full-loop SERVE on a real external client — reconcile divergent extraction runs to one canonical KB on a quality rubric; lexical retrieval sufficient (VectorRetriever seam not yet needed)). Vertical concept types declare IdRef relationship fields — DXA's capability.delivered_by; engagement.client / capabilities / runs / sow; client.systems / engagements (packages/okf/src/verticals/dxa.ts). These validated as frontmatter but were absent from the core hardcoded EDGE_FIELDS / EdgeType in packages/okf/src/graph.ts, so edges() silently dropped them: they never became traversable graph edges, so they were invisible to GraphRAG expand (MCP agentic foundation — tenant-scoped GraphRAG over the OKF KB).

Caught concretely: a capability's delivered_by link to its delivering workflow was unreachable in expansion — 2 capability atoms whose delivering workflow could not be reached. This violated "verticals extend, never fork" (Digital Experience Agency vertical as the first reference implementation) and single-source-of-truth (Dossier — The Knowledge Model (v0) principle 2): a vertical's relationship was declared on its type yet did not exist in the graph.

Options considered

  1. (A — chosen) Registry-driven edge fields. A TypeSpec declares its own edge fields (edges?: readonly string[]); edges() unions the core vocabulary with the registry (registeredEdgeFields()), core-first dedup so a vertical can never shadow a core label; EdgeType widens from a closed union to CoreEdgeType | (string & {}).
  2. (B — rejected) Bake the vertical fields into the core EDGE_FIELDS list. Add delivered_by / runs / sow / etc. directly to the hardcoded core vocabulary.

Decision

Adopt option A. A registered edge is a plain typed edge labeled by its field name — it never inherits produces semantics, so The produces edge is canonical on the producing process only (produces canonical on a process) is untouched and validateGraph needed no change. edges() reads CORE_EDGE_FIELDS first into a seen set, then appends each registeredEdgeFields() entry not already present (core wins its label). The DXA vertical now declares its edges on each type; engagement.team is intentionally excluded — it maps real people to role ids as free strings, not an id-ref edge.

Rationale

  • Rejecting B — it is the exact fork DEC-0006 forbids. Baking delivered_by / runs / sow into core would make them core concepts and force every future vertical to re-touch the core file to add its relationships — forking the core by accretion. It would also duplicate each vertical's relationship in two places (the type definition and the core list), an SSOT violation.
  • A was contained. The registry already existed and was already threaded into validation, so having edges() consult it was one import + one dedup loop — no new machinery, just closing a gap the registry was already designed to cover.
  • Semantics stay clean. Registered fields are plain typed edges, never read as produces; DEC-0007's canonical-producer rule and validateGraph are both untouched. The model doc (Dossier — The Knowledge Model (v0) §"Vertical edges (registry-driven)", lines 96–97) was synced to this schema-as-code.
  • verified. Implemented in code with 3 green regression tests; the build is green (tsc -b clean, vitest run 351 passed / 1 skipped). The seam was found by, and the fix proven against, a real served client KB.

Consequences

  • A vertical adds traversable graph edges the same way it adds a type — registerType(defineType(...)) with an edges list — so verticals extend the graph, never fork it. The core file is never touched to add a vertical relationship.
  • Default (un-narrowed) GraphRAG expand now correctly traverses delivered_by and the other DXA edges; the previously-unreachable capability→workflow links resolve.
  • A core label always wins its name on collision, so a vertical can never shadow or redefine a core relationship.

Known follow-up (deferred to keep this change atomic)

The MCP get_related tool still hardcodes edgeType as a closed z.enum of the 10 core labels (packages/mcp/src/server/server.ts:37-49). Default expansion already traverses registered edges correctly, but a caller cannot yet pass edgeType: "delivered_by" to NARROW expansion — the tool boundary would reject it. Closing it means deriving the tool enum from core + registeredEdgeFields() at server-wire time. Deferred as a follow-up against packages/mcp so this change stays atomic to the @dossier/okf graph layer. (MCP agentic foundation — tenant-scoped GraphRAG over the OKF KB.)

Review

Promote the follow-up to done when the MCP get_related enum is derived from the live registry (core + registered) at wire time, so narrowed expansion accepts vertical edge labels. Revisit the core-first dedup rule only if a vertical ever has a legitimate need to override a core label's meaning (it should not — that would be a fork).

Provenance

Decision and all facts verified in code at decision time: packages/okf/src/registry.ts (TypeSpec.edges + defineType options overload + registeredEdgeFields()), packages/okf/src/graph.ts (CoreEdgeType / CORE_EDGE_FIELDS + the registry-union in edges()), packages/okf/src/verticals/dxa.ts (each type declares its edges; engagement.team excluded), packages/okf/src/index.ts (exports), knowledge/model/index.md §96–97 (relationship-vocabulary doc synced to schema-as-code), packages/okf/test/graph.test.ts (3 regression tests, green). Build green: tsc -b clean, vitest run 351 passed / 1 skipped.