feat(openontology): Phase 2 + Phase 3 — storage, REST/SSE, MCP, RDF/SHACL, adapters, TUI, explorer (#101)
Some checks are pending
CI / build (push) Waiting to run
test / test (push) Waiting to run

Everything the two shipped PRD phases deferred, minus what is called out below.

Storage (Phase 2)
  @logicsrc/openontology gains a SQLite/Turso adapter. It hydrates the read
  model at open, serves reads synchronously — a query evaluator that awaits per
  triple pattern is unusable — and buffers mutations as SQL that flush() writes
  in one transaction. Versioned idempotent migrations; indexes over subject,
  predicate, entity-valued object, status, both time axes, aliases, and external
  ids; FTS5 for label/alias search. The append-only status log is replayed on
  open, so retractions, supersessions, and merge redirects survive a reopen.

REST + SSE + OpenAPI (Phase 2)
  16 paths under /api/ontologies in logicsrc-web, described at
  /api/ontologies/openapi and referencing the published JSON Schemas rather
  than restating them. No token is read-only; a curator token can apply; an
  agent token can propose and cannot apply. Idempotency-Key on mutations,
  revision ETags, 409 on a stale base revision, and an SSE stream that emits
  the same event objects as the JSON endpoint.

MCP (Phase 2)
  OpenOntology and OpenPRD surfaces on the standards server: spec/manifest/
  schema/queries and PRD spec/index as resources, 11 ontology tools and 6 PRD
  tools, 7 prompts. Read-only by default; OPENONTOLOGY_MCP_WRITABLE=1 buys
  proposals, never applies — the denial is the shared policy layer, not a
  second rule that could drift.

Interoperability (Phase 3)
  RDF/Turtle export and import of the reified profile, plus the plain triple
  for asserted relationships so a consumer wanting only the accepted graph gets
  one. SHACL for 5 of 7 constraint kinds; `unique` and `query` are reported as
  unmapped in both the return value and the generated Turtle, because a shape
  that quietly means something narrower is worse than no shape.

Source adapters (Phase 3)
  CSV, JSON, YAML, NDJSON, Markdown, generic JSON HTTP, and GitHub. All produce
  PROPOSED change-set operations with source, evidence selector, run id, and
  confidence attached; fetch is injected so ingestion is offline and testable.
  Each declares its capabilities, so "nothing was deleted upstream" is never
  confused with "this adapter cannot see deletions" — none of the seven can.

TUI + explorer
  Keyboard-first panels (types, entities, claims, sources, queries, change
  sets, validation, audit) as plain strings that survive SSH and 60 columns;
  status is a glyph and a word, never colour alone; the key bar wraps rather
  than truncating. Wired as `logicsrc ontology tui`. A read-only web explorer
  at /openontology/explore with entity and claim views showing status, both
  clocks, confidence, sources, evidence, and append-only history — plus an
  /openprd page for the companion standard.

Bugs found and fixed while testing
  - the API built a new engine per request, so `explain` could never find a
    resultId from a prior request; engines are now cached per role
  - the TUI status bar called engine.validateOntologyPackage(), appending a
    package.validated event on every repaint; it now uses the pure validator

Verification: 76 new tests (527 total across the monorepo, all passing); full
build green; the libSQL adapter is exercised against real files, the API
through its route handlers, and MCP over an in-memory transport.

Not included: PWA review/approval write flows (they need an auth story this
deployment does not have), OWL/RDFS mappings, SPARQL/Cypher/Datalog query
adapters, and Phase 4 governed actions. The compatibility matrix marks those
"planned", not "supported".

Refs: prd/0001-add-logicsrc-openontology-spec.md

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-07-28 05:10:33 -07:00 committed by GitHub
parent 296775e003
commit da5f6f8381
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
53 changed files with 6939 additions and 23 deletions

View file

@ -3,6 +3,7 @@ import { coinPayPlugin } from "@logicsrc/plugin-coinpay";
import { emailAccountsPlugin } from "@logicsrc/plugin-email-accounts";
import { socialAccountsPlugin } from "@logicsrc/plugin-social-accounts";
import { uGigPlugin } from "@logicsrc/plugin-ugig";
export { renderOntologyTui, renderOntologyKeyHelp, PANEL_KEYS, type OntologyPanel, type OntologyTuiOptions } from "./openontology.js";
export { ArcadeRegistry, createDefaultArcadeRegistry, renderArcadeList, renderArcadeSnapshot, runArcadeSession } from "./arcade/index.js";
export type { ArcadeEvent, GameAction, GameContext, GameControl, KeyEvent, TaskEvent, TaskSnapshot, TerminalFrame, WaitingGame } from "./arcade/index.js";

View file

@ -0,0 +1,94 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, describe, expect, it } from "vitest";
import {
createOntologyEngine,
initOntologyPackage,
loadOntologyPackage,
localActor
} from "@logicsrc/openontology";
import { renderOntologyKeyHelp, renderOntologyTui, PANEL_KEYS } from "./openontology.js";
const dirs: string[] = [];
afterAll(() => {
for (const dir of dirs) rmSync(dir, { recursive: true, force: true });
});
function engine() {
const dir = mkdtempSync(join(tmpdir(), "tui-ontology-"));
dirs.push(dir);
initOntologyPackage(dir, { id: "test-ecosystem", now: "2026-07-26T00:00:00Z" });
return createOntologyEngine({
package: loadOntologyPackage(dir),
actor: localActor("curator"),
clock: () => "2026-07-26T00:00:00Z"
});
}
describe("ontology TUI", () => {
it("renders a bordered frame with the key bar and status line", () => {
const output = renderOntologyTui(engine());
const lines = output.split("\n");
expect(lines[0]!.startsWith("┌")).toBe(true);
expect(lines.at(-1)!.startsWith("└")).toBe(true);
expect(output).toContain("e entities");
expect(output).toContain("q quit");
expect(output).toContain("asserted claims");
});
it("never exceeds the requested width, and stays usable when narrow", () => {
for (const width of [60, 78, 120]) {
const lines = renderOntologyTui(engine(), { width }).split("\n");
const widths = new Set(lines.map((line) => [...line].length));
expect(widths.size).toBe(1);
expect([...widths][0]).toBe(Math.max(width, 60));
}
});
it("shows every panel", () => {
const e = engine();
expect(renderOntologyTui(e, { panel: "types" })).toContain("Person");
expect(renderOntologyTui(e, { panel: "entities" })).toContain("Alice Reyes");
expect(renderOntologyTui(e, { panel: "claims" })).toContain("worksOn");
expect(renderOntologyTui(e, { panel: "sources" })).toContain("web-page");
expect(renderOntologyTui(e, { panel: "queries" })).toContain("contributors");
expect(renderOntologyTui(e, { panel: "changesets" })).toContain("no change sets");
expect(renderOntologyTui(e, { panel: "violations" })).toContain("no errors");
expect(renderOntologyTui(e, { panel: "audit" })).toContain("no events");
// Rendering is read-only: repainting must not append to the audit log.
expect(renderOntologyTui(e, { panel: "audit" })).toContain("no events");
});
it("distinguishes claim status by glyph and word, not colour", () => {
const claims = renderOntologyTui(engine(), { panel: "claims", rows: 20 });
expect(claims).toMatch(/✓ asserted/);
// No ANSI escapes: the panel must survive a monochrome terminal.
expect(claims.includes("[")).toBe(false);
});
it("surfaces proposed change sets and disputes in the status bar", () => {
const e = engine();
e.createOntologyChangeSet({
title: "pending",
operations: [
{
op: "assert-claim",
value: {
subject: "test:person:alice",
predicate: "worksOn",
object: { entity: "test:project:docs-portal" },
sources: ["test:source:repo"]
}
}
]
});
expect(renderOntologyTui(e)).toContain("1 proposed");
expect(renderOntologyTui(e, { panel: "changesets" })).toContain("pending");
});
it("lists the keys it binds", () => {
expect(renderOntologyKeyHelp()).toContain("v: validate");
expect(PANEL_KEYS.map((entry) => entry.key)).toContain("q");
});
});

View file

@ -0,0 +1,282 @@
import { validateOntologyPackage, type OntologyEngine, type ValidationReport } from "@logicsrc/openontology";
/**
* Keyboard-first OpenOntology panels.
*
* Rendered as plain strings so they work over SSH, in tmux, in a narrow
* terminal, and in tests. Graph diagrams are optional in the standard; lists,
* trees, and paths are what this shows.
*/
export type OntologyPanel = "types" | "entities" | "claims" | "sources" | "queries" | "changesets" | "violations" | "audit";
export const PANEL_KEYS: Array<{ key: string; panel: OntologyPanel | "quit" | "search"; label: string }> = [
{ key: "/", panel: "search", label: "search" },
{ key: "t", panel: "types", label: "types" },
{ key: "e", panel: "entities", label: "entities" },
{ key: "c", panel: "claims", label: "claims" },
{ key: "s", panel: "sources", label: "sources" },
{ key: "r", panel: "queries", label: "queries" },
{ key: "g", panel: "changesets", label: "change sets" },
{ key: "v", panel: "violations", label: "validate" },
{ key: "a", panel: "audit", label: "audit" },
{ key: "q", panel: "quit", label: "quit" }
];
export interface OntologyTuiOptions {
panel?: OntologyPanel;
/** Terminal width. Clamped to a usable minimum. */
width?: number;
/** Rows of detail to show. */
rows?: number;
selected?: string;
}
const MIN_WIDTH = 60;
export function renderOntologyTui(engine: OntologyEngine, options: OntologyTuiOptions = {}): string {
const width = Math.max(options.width ?? 78, MIN_WIDTH);
const rows = options.rows ?? 8;
const panel = options.panel ?? "entities";
const manifest = engine.getOntologyManifest();
const schema = engine.getOntologySchema();
const lines: string[] = [];
const inner = width - 2;
const rule = (left: string, right: string, fill = "─") => `${left}${fill.repeat(inner)}${right}`;
const row = (text: string) => `${clip(` ${text}`, inner)}`;
const heading = (text: string) => `${clip(`${text} `, inner, "─")}`;
lines.push(rule("┌", "┐"));
lines.push(row(`OpenOntology: ${manifest.id}@${manifest.version} rev ${engine.store.revision()}`));
// The key bar wraps rather than truncating: a binding you cannot see is a
// binding you do not have.
for (const line of wrap(PANEL_KEYS.map((entry) => `${entry.key} ${entry.label}`), inner - 1)) {
lines.push(row(line));
}
lines.push(heading(panelTitle(panel)));
for (const line of panelBody(engine, panel, { rows, selected: options.selected, width: inner })) {
lines.push(row(line));
}
// Status bar: what needs a human, always visible.
const changeSets = engine.store.listChangeSets();
const proposed = changeSets.filter((entry) => entry.status === "proposed").length;
const conflicted = changeSets.filter((entry) => entry.status === "conflicted").length;
const report = validate(engine);
const disputed = engine.store.listClaims({ status: ["disputed"] }).length;
lines.push(heading("status"));
lines.push(
row(
`${proposed} proposed ${disputed} disputed ${conflicted} conflicts ` +
`${report.counts.error} errors ${report.counts.warning} warnings`
)
);
lines.push(row(`${schema.entityTypes.length} types ${engine.store.listEntities().length} entities ${engine.store.listClaims({ status: ["asserted"] }).length} asserted claims`));
lines.push(rule("└", "┘"));
return lines.join("\n");
}
/**
* Validate without emitting an event.
*
* `engine.validateOntologyPackage()` records a package.validated event, which
* is right for a CLI run and wrong for a panel that repaints on every keypress:
* rendering a read-only view must not write to the audit log.
*/
function validate(engine: OntologyEngine): ValidationReport {
return validateOntologyPackage({
manifest: engine.getOntologyManifest(),
schema: engine.getOntologySchema(),
data: {
entities: engine.store.listEntities(),
claims: engine.store.listClaims({
status: ["asserted", "proposed", "disputed", "retracted", "superseded", "derived"]
}),
sources: engine.store.listSources(),
evidence: engine.store.listEvidence()
},
files: []
});
}
function panelTitle(panel: OntologyPanel): string {
switch (panel) {
case "types":
return "Types";
case "entities":
return "Entities";
case "claims":
return "Claims";
case "sources":
return "Sources";
case "queries":
return "Saved queries";
case "changesets":
return "Change sets";
case "violations":
return "Validation";
default:
return "Audit";
}
}
function panelBody(
engine: OntologyEngine,
panel: OntologyPanel,
view: { rows: number; selected?: string; width: number }
): string[] {
const { rows } = view;
switch (panel) {
case "types": {
const counts = new Map<string, number>();
for (const entity of engine.store.listEntities()) {
counts.set(entity.type, (counts.get(entity.type) ?? 0) + 1);
}
return engine
.getOntologySchema()
.entityTypes.slice(0, rows)
.map((type) => `${pad(type.id, 20)} ${String(counts.get(type.id) ?? 0).padStart(5)} ${type.label}`);
}
case "entities": {
const entities = engine.store.listEntities({ limit: rows });
return entities.map(
(entity) =>
`${statusMark(entity.status ?? "active")} ${pad(entity.canonicalName, 26)} ${pad(entity.type, 14)} ${entity.id}`
);
}
case "claims": {
const claims = engine.store.listClaims({
status: ["asserted", "proposed", "disputed", "retracted", "superseded", "derived"],
limit: rows
});
return claims.map((claim) => {
const object = "entity" in claim.object ? claim.object.entity : JSON.stringify(claim.object.value);
const confidence = claim.confidence === undefined ? " — " : claim.confidence.toFixed(2);
const from = claim.validTime?.from?.slice(0, 10) ?? "—";
// Status is never conveyed by colour alone: a glyph and the word.
return `${statusMark(claim.status)} ${pad(claim.status, 10)} ${pad(shorten(claim.subject), 22)} ${pad(claim.predicate, 14)} ${pad(shorten(object), 22)} ${confidence} ${from} src:${claim.sources?.length ?? 0}`;
});
}
case "sources": {
return engine.store
.listSources()
.slice(0, rows)
.map(
(source) =>
`${source.stale ? "!" : "·"} ${pad(source.sourceType, 14)} ${pad(source.license ?? "unknown", 12)} ${source.uri}`
);
}
case "queries": {
return engine
.getOntologySchema()
.queries.slice(0, rows)
.map((query) => `${pad(query.id, 30)} ${query.description}`);
}
case "changesets": {
const changeSets = engine.store.listChangeSets().slice(0, rows);
if (changeSets.length === 0) return ["(no change sets in this session)"];
return changeSets.map((changeSet) => {
const approvals = engine.store.listApprovals(changeSet.id).length;
const required = changeSet.requiredApprovals ?? 0;
return `${statusMark(changeSet.status)} ${pad(changeSet.status, 10)} ${pad(changeSet.title, 34)} ops:${String(changeSet.operations.length).padStart(3)} appr:${approvals}/${required}`;
});
}
case "violations": {
const report = validate(engine);
const findings = report.findings.filter((finding) => finding.severity !== "info").slice(0, rows);
if (findings.length === 0) return ["✓ no errors, warnings, or policy findings"];
return findings.map(
(finding) => `${severityMark(finding.severity)} ${pad(finding.code, 24)} ${finding.message}`
);
}
default: {
const events = engine.listEvents({ limit: rows });
if (events.length === 0) return ["(no events in this session)"];
return events.map(
(event) => `${event.at.slice(0, 19)} ${pad(event.type, 20)} ${pad(event.actor, 20)} ${event.subject ?? ""}`
);
}
}
}
/** Glyphs, not colour, so the status survives a monochrome terminal. */
function statusMark(status: string): string {
switch (status) {
case "asserted":
case "active":
case "applied":
return "✓";
case "proposed":
return "?";
case "disputed":
case "conflicted":
return "!";
case "retracted":
case "rejected":
return "×";
case "superseded":
case "merged":
return "→";
case "derived":
return "ƒ";
case "archived":
return "▪";
default:
return "·";
}
}
function severityMark(severity: string): string {
return severity === "error" ? "✗" : severity === "warning" ? "!" : "·";
}
/** Pack items onto as many lines as the width needs. */
function wrap(items: string[], width: number): string[] {
const lines: string[] = [];
let current = "";
for (const item of items) {
const candidate = current ? `${current} ${item}` : item;
if (candidate.length > width && current) {
lines.push(current);
current = item;
} else {
current = candidate;
}
}
if (current) lines.push(current);
return lines;
}
function pad(value: string, width: number): string {
const text = String(value ?? "");
return text.length >= width ? `${text.slice(0, Math.max(width - 1, 1))}` : text.padEnd(width);
}
function clip(value: string, width: number, fill = " "): string {
const text = value.length > width ? `${value.slice(0, width - 1)}` : value;
return text.padEnd(width, fill);
}
/** Compact ids read better in a narrow column without their prefix. */
function shorten(id: string): string {
const parts = String(id).split(":");
return parts.length > 2 ? parts.slice(1).join(":") : String(id);
}
/** One-line help for the panel keys, for a footer or `--help`. */
export function renderOntologyKeyHelp(): string {
return PANEL_KEYS.map((entry) => `${entry.key}: ${entry.label}`).join(" ");
}