Skip to main content

Note: Knowledge graph data model

Implementation reference for AD-021. Schemas, Go types, REST routes, change-set op types, and permission mapping.

Framework layer (Apache, terms/)

Persisted relations

ConceptRelation gains identity, a note, and validity, and is persisted by every terms backend:

type ConceptRelation struct {
ID string `json:"id"`
SourceID string `json:"source_id"`
TargetID string `json:"target_id"`
RelationType string `json:"relation_type"` // graph.Label* constants
Note string `json:"note,omitempty"`
Validity *graph.Validity `json:"validity,omitempty"`
CreatedAt time.Time `json:"created_at"`
}

Terminology interface additions:

AddRelation(ctx, rel ConceptRelation) error // upsert by ID
DeleteRelation(ctx, id string) error
RelationsOf(ctx, conceptID string, scope *graph.Scope) ([]ConceptRelation, error) // both directions
ListRelations(ctx, scope *graph.Scope) ([]ConceptRelation, error)

SQLite baseline schema (framework terms/sqlite.go — part of the base migration; the platform is not live, so the schema is authored as if designed this way from the start):

CREATE TABLE tb_relations (
id TEXT PRIMARY KEY,
source_id TEXT NOT NULL REFERENCES tb_concepts(id) ON DELETE CASCADE,
target_id TEXT NOT NULL REFERENCES tb_concepts(id) ON DELETE CASCADE,
relation TEXT NOT NULL,
note TEXT NOT NULL DEFAULT '',
valid_from TEXT, -- RFC3339, NULL = unbounded
valid_to TEXT,
tags TEXT NOT NULL DEFAULT '{}', -- JSON object
created_at TEXT NOT NULL
);
CREATE INDEX idx_tb_relations_source ON tb_relations(source_id);
CREATE INDEX idx_tb_relations_target ON tb_relations(target_id);

Term validity

Term gains Validity *graph.Validity (market/time scoping of a designation or of a status). Stored as three columns on tb_terms (valid_from, valid_to, tags) in SQLite and PostgreSQL. Lookup gains Scope *graph.Scope in LookupOptions; a nil scope means "now, no tags".

Status transition policy

terms.ValidateTransition(from, to model.TermStatus) error plus terms.IsGovernedTransition(from, to) bool. Governed transitions (require a change-set on the platform): any transition to forbidden or preferred, and any transition from forbidden. Disallowed outright: none (history is the guard, not a trap), but forbidden → preferred must pass through a governed change-set even on the platform's direct-edit path.

The terms bundle (terms/ktb)

The .terms.json bundle carries a relations array alongside concepts, emitted deterministically (sorted by ID) under the existing schema version.

Platform layer (AGPL)

PostgreSQL terms parity

bowrain/terms/postgres.go carries the same relations table (workspace-scoped: PRIMARY KEY (workspace_id, id)) and validity columns on tb_terms as part of its baseline schema.

New package bowrain/knowledge

A PostgreSQL store (NewPostgresKnowledgeStore, migration namespace knowledge_schema_migrations), mirroring the brand store: bowrain-server runs exclusively on PostgreSQL, and the knowledge graph is a server-side governance subsystem with no standalone-kapi or desktop-cache use. The change-set state machine, op validation, governed/ordinary classification, separation-of-duties merge gate, and conflict detection are pure functions in bowrain/knowledge/changeset.go, unit-tested without a database; the SQL layer adds compile-time interface checks, scan round-trips, and //go:build integration CRUD tests. Tables (PRIMARY KEY (workspace_id, id) where workspace-scoped; TIMESTAMPTZ timestamps, JSONB for snapshot/payload/locales):

kg_markets (
workspace_id TEXT, id TEXT, name TEXT, description TEXT NOT NULL DEFAULT '',
locales JSONB NOT NULL DEFAULT '[]', -- array of locale IDs
created_at TIMESTAMPTZ, updated_at TIMESTAMPTZ,
PRIMARY KEY (workspace_id, id)
)

kg_observations (
workspace_id TEXT, id TEXT, concept_id TEXT,
kind TEXT, -- competitor|customer|style_guide|regulatory|web|internal
quote TEXT, source TEXT, url TEXT NOT NULL DEFAULT '',
locale TEXT NOT NULL DEFAULT '', market TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
created_by TEXT, created_at TIMESTAMPTZ,
PRIMARY KEY (workspace_id, id)
)

kg_comments (
workspace_id TEXT, id TEXT, concept_id TEXT,
parent_id TEXT NOT NULL DEFAULT '', -- threaded; empty = top-level
changeset_id TEXT NOT NULL DEFAULT '', -- set when the thread belongs to a change-set
body TEXT, author TEXT, created_at TIMESTAMPTZ,
resolved BOOLEAN NOT NULL DEFAULT FALSE,
PRIMARY KEY (workspace_id, id)
)

kg_concept_revisions (
workspace_id TEXT, concept_id TEXT, rev BIGINT,
snapshot JSONB, -- terms.Concept + relations delta
summary TEXT, -- human-readable change summary
actor TEXT, changeset_id TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ,
PRIMARY KEY (workspace_id, concept_id, rev)
)

kg_changesets (
workspace_id TEXT, id TEXT, name TEXT, description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'draft', -- draft|in_review|approved|merged|abandoned
created_by TEXT, created_at TIMESTAMPTZ, updated_at TIMESTAMPTZ,
submitted_at TIMESTAMPTZ, merged_at TIMESTAMPTZ, merged_by TEXT NOT NULL DEFAULT '',
PRIMARY KEY (workspace_id, id)
)

kg_changeset_ops (
workspace_id TEXT, changeset_id TEXT, seq BIGINT,
op TEXT, -- op type, see below
payload JSONB, -- op-specific
base_rev BIGINT NOT NULL DEFAULT 0, -- concept revision the op was authored against
created_by TEXT, created_at TIMESTAMPTZ,
PRIMARY KEY (workspace_id, changeset_id, seq)
)

kg_changeset_reviews (
workspace_id TEXT, changeset_id TEXT, reviewer TEXT,
verdict TEXT, -- approve|reject
comment TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ,
PRIMARY KEY (workspace_id, changeset_id, reviewer)
)

kg_pilots (
workspace_id TEXT, changeset_id TEXT, project_id TEXT, stream TEXT,
created_by TEXT, created_at TIMESTAMPTZ,
PRIMARY KEY (workspace_id, changeset_id, project_id, stream)
)

Change-set op types

op + JSON payload; each op is self-contained and re-validated at merge:

oppayload
concept.createfull terms.Concept
concept.update{concept_id, domain?, definition?, properties?}
concept.delete{concept_id}
term.add{concept_id, term}
term.update{concept_id, locale, text, term} (locale+text identify)
term.remove{concept_id, locale, text}
term.status{concept_id, locale, text, from, to, validity?} — governed when IsGovernedTransition
relation.addfull ConceptRelation — governed when relation is REPLACED_BY
relation.remove{relation_id}
voice.rule.add{profile_id, list: preferred|forbidden|competitor, rule} (rule carries concept_id) — governed
voice.rule.remove{profile_id, list, term} — governed

A change-set containing any governed op requires in_review → approved (≥1 approval, approver ≠ author) before merge. A change-set with only ordinary ops may merge directly from draft by its author.

Merge applies ops in sequence inside one transaction per backend store: terms ops via the workspace terms store, voice ops via the brand store (version-bumping profiles exactly like AD-019 promotion). Before applying, each op's base_rev is compared with the concept's current revision; a mismatch marks the op conflicted and blocks the merge with a per-op conflict report (re-basing is: reopen the op, re-validate, resubmit). Merge emits one revision per touched concept (changeset_id set), then deletes pilot shadows.

Pilot copies the draft's resulting concepts into the project stream's terms shadow (AddConceptWithStream) and, for voice ops, sets the stream's brand-voice binding property to a candidate profile built with brand.CandidateWithRule semantics. Abandon/merge removes shadows and restores stream properties.

Blast radius

knowledge.EvaluateChangeSet(ctx, ws, cs) (*ChangeSetImpact, error) walks stored blocks per project/stream (default main plus pilot streams), running the before/after vocabulary + term-enforcement matchers, and returns:

type ChangeSetImpact struct {
TotalBlocks int
AffectedBlocks int
NewViolations int
Resolved int
Words int // word count of affected blocks
Projects []ProjectImpact // per project → per collection → per locale counts
Samples []BlockSample // capped sample of affected blocks
}

Concept-level "where used" (GET /concepts/:cid/blast-radius) is the same walk filtered to one concept's terms, without a candidate side.

Events

New event types (wired through the platform event bus → audit chain, notifications, SSE, desktop watch): concept.created, concept.updated, concept.deleted, concept.term.status_changed, concept.relation.added, concept.relation.removed, observation.added, concept.comment.added, changeset.created, changeset.submitted, changeset.approved, changeset.rejected, changeset.merged, changeset.abandoned, pilot.started, pilot.stopped.

REST API

Workspace-scoped, AD-011 conventions. /:ws/concepts is the terminology API, and every consumer (web, desktop, Pulse, MCP) uses it. There is no whole-graph endpoint: relations are read one concept at a time through GET /:ws/concepts/:cid/relations (the concept and its direct, 1-hop edges), which is what the per-concept dashboard's relations widget consumes.

GET /:ws/concepts ?q&status&domain&market&locale&source&offset&limit
POST /:ws/concepts (ordinary create; governed parts rejected with hint)
GET /:ws/concepts/:cid
PUT /:ws/concepts/:cid (ordinary edits only; governed → 409 + change-set hint)
DELETE /:ws/concepts/:cid (governed)
GET /:ws/concepts/:cid/story merged timeline (revisions, observations, comments, change-sets)
GET /:ws/concepts/:cid/relations ?as_of&market
POST /:ws/concepts/:cid/relations
DELETE /:ws/concepts/:cid/relations/:rid
GET /:ws/concepts/:cid/blast-radius where-used
GET /:ws/concepts/:cid/observations
POST /:ws/concepts/:cid/observations
DELETE /:ws/concepts/:cid/observations/:oid
GET /:ws/concepts/:cid/comments
POST /:ws/concepts/:cid/comments {body, parent_id?}
POST /:ws/concepts/:cid/comments/:id/resolve
DELETE /:ws/concepts/:cid/comments/:id

GET /:ws/markets POST /:ws/markets PUT/DELETE /:ws/markets/:mid

GET /:ws/changesets ?status
POST /:ws/changesets
GET /:ws/changesets/:id includes ops, reviews, pilots
PATCH /:ws/changesets/:id name/description (draft only)
POST /:ws/changesets/:id/ops append op
DELETE /:ws/changesets/:id/ops/:seq (draft only)
POST /:ws/changesets/:id/submit draft → in_review
POST /:ws/changesets/:id/approve {comment?} (SoD: reviewer ≠ author)
POST /:ws/changesets/:id/reject {comment?}
POST /:ws/changesets/:id/merge
POST /:ws/changesets/:id/abandon
GET /:ws/changesets/:id/blast-radius
POST /:ws/changesets/:id/pilots {project_id, stream}
DELETE /:ws/changesets/:id/pilots/:project/:stream

Permissions

ActionPermission
Read concepts/graph/story/changesetsview_content (workspace member)
Ordinary concept edits, observations, commentsmanage_terms
Markets CRUDmanage_terms
Create/edit own change-set, pilotsmanage_terms
Approve/reject change-setmanage_brand, and reviewer ≠ author
Merge governed change-setmanage_brand after approval
Voice ops inside change-setsmanage_brand

CLI / MCP (bowrain plugin)

There are no dedicated kapi concepts / kapi experiments / kapi terms pull commands. Governed terminology rides ordinary project transport instead (bowrain/plugin/commands/conceptsync.go, folded into kapi pull / kapi push):

  • kapi pull paginates GET /:ws/concepts, fetches each concept's relations (GET /:ws/concepts/:cid/relations, bounded fan-out), writes both into the project's bound terms store via the framework terms API (AddConcept / AddRelation), and records a ConceptBaseline in the sync cache. The snapshot is identical in shape to a local terms store, so kapi check --ship --terms then gates offline in CI against governed terminology.
  • kapi push diffs the local terms store against that baseline. Ordinary edits (definitions, notes, non-governed terms, non-REPLACED_BY relations) go up directly through the concept/relation endpoints; governed edits — a term transition where IsGovernedTransition holds, a forbidden-term removal, a new concept carrying a governed term, a REPLACED_BY relation, a concept delete — are bundled into one change-set (POST /:ws/changesets → append ops → submit), the same reviewed path the hub enforces. An ordinary concept PUT neutralizes any pending governed term transition to its baseline status so the direct endpoint never entails a governed transition the server would reject with 409.

MCP read tools remain: concept_search, concept_story, experiment_status.

Frontend

The per-concept surface is the framework package @neokapi/concept-ui (packages/concept-ui, Apache) — a data-source-agnostic concept browser and dashboard. It is driven through one small ConceptDataSource adapter: kapi-desktop binds it to a local SQLite terms store (core reads plus the editable-core mutations), and bowrain binds it to the REST API (core plus the rich reads — named markets, observations, comments, the revision timeline, where-used). Components gate their sections on resolved capabilities (resolveCapabilities), so a minimal local source degrades to terms, relations, geography, constraints, and a synthesized timeline. ConceptList is the list surface; ConceptDashboard composes the section panels (MarketsPanel = geography, ConstraintsPanel, RelationsPanel = the local 1-hop relations widget with RELATION_COLLAPSE_THRESHOLD-driven "N related" grouping, ConceptTimeline, ObservationsPanel, CommentsPanel).

One BrandHub shell (web + desktop, shared in bowrain/packages/ui) wraps it with sub-navigation: Concepts (the @neokapi/concept-ui list + dashboard), Voice (existing pages re-homed), Experiments (list, detail with op diff + blast radius + reviews + pilots, what-if wizard), Activity (brand-scoped event feed), Dashboard (scores, drift, coverage, pending decisions). There is no graph canvas; every new component has a story and vitest coverage; the desktop app proxies all routes through Wails bindings (governance.go pattern). The workspace-scoped hub has no project to watch, so freshness is React Query's own refetch (per-hook staleTime + refetch-on-focus) plus the mutation-driven invalidation the brand hooks already do on every write; there are no dedicated concept-changed / changeset-changed Wails events. When a project is being watched, its existing brand-voice-changed / terms-changed events invalidate the hub's query keys for cross-client freshness, so no new bindings (and no binding regen) are needed.